@orchestero/codex-gateway 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.
@@ -0,0 +1,450 @@
1
+ type JsonRecord = Record<string, unknown>;
2
+ type JsonRpcId = number | string;
3
+
4
+ type JsonRpcMessage = JsonRecord & {
5
+ id?: JsonRpcId;
6
+ method?: string;
7
+ params?: unknown;
8
+ };
9
+
10
+ type CodexAppServerConnection = {
11
+ close: () => Promise<void>;
12
+ messages: AsyncIterable<unknown>;
13
+ send: (message: JsonRpcMessage) => void;
14
+ };
15
+
16
+ type CodexAppServerState = {
17
+ connection: CodexAppServerConnection;
18
+ messages: AsyncIterator<unknown>;
19
+ };
20
+
21
+ type CodexAppServerOptions = {
22
+ args?: string[];
23
+ clientInfo?: {
24
+ name: string;
25
+ title: string;
26
+ version: string;
27
+ };
28
+ command?: string;
29
+ connect?: () => CodexAppServerConnection;
30
+ model?: string;
31
+ };
32
+
33
+ type CodexAppServerRequest = {
34
+ input: string;
35
+ onThreadId?: (threadId: string) => Promise<void> | void;
36
+ threadId?: string;
37
+ workingDirectory?: string;
38
+ };
39
+
40
+ const defaultClientInfo = {
41
+ name: "orchestero_codex_gateway",
42
+ title: "Orchestero Codex Gateway",
43
+ version: "0.0.0",
44
+ };
45
+ const incompleteTurnMessage =
46
+ "Codex app-server closed before completing a turn.";
47
+ const initializationMessage =
48
+ "Codex app-server closed before initialization completed.";
49
+
50
+ function isRecord(value: unknown): value is JsonRecord {
51
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
52
+ }
53
+
54
+ function getRecord(value: unknown, key: string): JsonRecord | undefined {
55
+ if (!isRecord(value)) {
56
+ return undefined;
57
+ }
58
+
59
+ const nextValue = value[key];
60
+ if (!isRecord(nextValue)) {
61
+ return undefined;
62
+ }
63
+
64
+ return nextValue;
65
+ }
66
+
67
+ function getString(value: unknown, key: string): string | undefined {
68
+ if (!isRecord(value)) {
69
+ return undefined;
70
+ }
71
+
72
+ const nextValue = value[key];
73
+ if (typeof nextValue !== "string") {
74
+ return undefined;
75
+ }
76
+
77
+ return nextValue;
78
+ }
79
+
80
+ function getResponseError(message: unknown) {
81
+ return getRecord(message, "error");
82
+ }
83
+
84
+ function getResponseId(message: unknown) {
85
+ if (
86
+ !isRecord(message) ||
87
+ (typeof message.id !== "number" && typeof message.id !== "string")
88
+ ) {
89
+ return undefined;
90
+ }
91
+
92
+ return message.id;
93
+ }
94
+
95
+ function isServerRequest(message: unknown): message is JsonRpcMessage & {
96
+ id: JsonRpcId;
97
+ method: string;
98
+ } {
99
+ return (
100
+ isRecord(message) &&
101
+ (typeof message.id === "number" || typeof message.id === "string") &&
102
+ typeof message.method === "string"
103
+ );
104
+ }
105
+
106
+ function getThreadId(message: unknown) {
107
+ return getString(getRecord(getRecord(message, "result"), "thread"), "id");
108
+ }
109
+
110
+ function getResultTurnId(message: unknown) {
111
+ return getString(getRecord(getRecord(message, "result"), "turn"), "id");
112
+ }
113
+
114
+ function getNotificationTurnId(message: unknown) {
115
+ const params = getRecord(message, "params");
116
+
117
+ return (
118
+ getString(params, "turnId") || getString(getRecord(params, "turn"), "id")
119
+ );
120
+ }
121
+
122
+ function getCompletedTurnErrorMessage(message: unknown) {
123
+ const turn = getRecord(getRecord(message, "params"), "turn");
124
+ if (getString(turn, "status") !== "failed") {
125
+ return undefined;
126
+ }
127
+
128
+ return (
129
+ getString(getRecord(turn, "error"), "message") ||
130
+ "Codex app-server turn failed."
131
+ );
132
+ }
133
+
134
+ function getAgentMessageDelta(message: unknown, turnId: string) {
135
+ if (!isRecord(message) || message.method !== "item/agentMessage/delta") {
136
+ return undefined;
137
+ }
138
+
139
+ if (getNotificationTurnId(message) !== turnId) {
140
+ return undefined;
141
+ }
142
+
143
+ return getString(message.params, "delta");
144
+ }
145
+
146
+ function isTurnCompleted(message: unknown, turnId: string) {
147
+ return (
148
+ isRecord(message) &&
149
+ message.method === "turn/completed" &&
150
+ getNotificationTurnId(message) === turnId
151
+ );
152
+ }
153
+
154
+ function connectStdio(
155
+ command: string,
156
+ args: string[],
157
+ ): CodexAppServerConnection {
158
+ const process = Bun.spawn([command, ...args], {
159
+ stdin: "pipe",
160
+ stdout: "pipe",
161
+ stderr: "inherit",
162
+ });
163
+
164
+ return {
165
+ async close() {
166
+ process.stdin.end();
167
+ process.kill();
168
+ await process.exited;
169
+ },
170
+ messages: readJsonLines(process.stdout),
171
+ send(message) {
172
+ process.stdin.write(`${JSON.stringify(message)}\n`);
173
+ process.stdin.flush();
174
+ },
175
+ };
176
+ }
177
+
178
+ async function* readJsonLines(stream: ReadableStream<Uint8Array>) {
179
+ const decoder = new TextDecoder();
180
+ let buffer = "";
181
+
182
+ for await (const chunk of stream) {
183
+ buffer += decoder.decode(chunk, { stream: true });
184
+ const lines = buffer.split("\n");
185
+ buffer = lines.pop() || "";
186
+
187
+ for (const line of lines) {
188
+ if (line.trim()) {
189
+ yield JSON.parse(line);
190
+ }
191
+ }
192
+ }
193
+
194
+ buffer += decoder.decode();
195
+ if (buffer.trim()) {
196
+ yield JSON.parse(buffer);
197
+ }
198
+ }
199
+
200
+ function create(options: CodexAppServerOptions = {}) {
201
+ const {
202
+ args = ["app-server"],
203
+ clientInfo = defaultClientInfo,
204
+ command = "codex",
205
+ connect = () => connectStdio(command, args),
206
+ model,
207
+ } = options;
208
+ let state: CodexAppServerState | null = null;
209
+ let nextRequestId = 0;
210
+ let queue = Promise.resolve();
211
+
212
+ const close = async () => {
213
+ const currentState = state;
214
+ state = null;
215
+
216
+ if (currentState) {
217
+ await currentState.connection.close();
218
+ }
219
+ };
220
+ const sendRequest = (
221
+ currentState: CodexAppServerState,
222
+ method: string,
223
+ params: JsonRecord,
224
+ ) => {
225
+ const id = nextRequestId;
226
+ nextRequestId += 1;
227
+ currentState.connection.send({ id, method, params });
228
+
229
+ return id;
230
+ };
231
+ const sendRequestAndResetOnError = async (
232
+ currentState: CodexAppServerState,
233
+ method: string,
234
+ params: JsonRecord,
235
+ ) => {
236
+ try {
237
+ return sendRequest(currentState, method, params);
238
+ } catch (error) {
239
+ await close();
240
+ throw error;
241
+ }
242
+ };
243
+ const acquireTurn = async () => {
244
+ const previous = queue;
245
+ const { promise: current, resolve: release } =
246
+ Promise.withResolvers<void>();
247
+ queue = previous.then(() => current);
248
+ await previous;
249
+
250
+ return release;
251
+ };
252
+ const sendNotification = (
253
+ currentState: CodexAppServerState,
254
+ method: string,
255
+ params: JsonRecord,
256
+ ) => {
257
+ currentState.connection.send({ method, params });
258
+ };
259
+ const sendUnsupportedRequestResponse = async (
260
+ currentState: CodexAppServerState,
261
+ id: JsonRpcId,
262
+ ) => {
263
+ try {
264
+ currentState.connection.send({
265
+ id,
266
+ error: {
267
+ code: -32601,
268
+ message: "Unsupported Codex app-server request.",
269
+ },
270
+ });
271
+ } catch (error) {
272
+ await close();
273
+ throw error;
274
+ }
275
+ };
276
+ const readMessage = async (currentState: CodexAppServerState) => {
277
+ const result = await currentState.messages.next();
278
+ if (result.done) {
279
+ await close();
280
+ return undefined;
281
+ }
282
+
283
+ return result.value;
284
+ };
285
+ const readResponse = async (
286
+ currentState: CodexAppServerState,
287
+ id: number,
288
+ closedMessage: string,
289
+ pendingMessages: unknown[] = [],
290
+ ) => {
291
+ while (true) {
292
+ const message = await readMessage(currentState);
293
+
294
+ if (message === undefined) {
295
+ throw new Error(closedMessage);
296
+ }
297
+
298
+ if (isServerRequest(message)) {
299
+ await sendUnsupportedRequestResponse(currentState, message.id);
300
+ continue;
301
+ }
302
+
303
+ const responseId = getResponseId(message);
304
+ if (responseId !== id) {
305
+ if (responseId === undefined) {
306
+ pendingMessages.push(message);
307
+ }
308
+ continue;
309
+ }
310
+
311
+ const error = getResponseError(message);
312
+ if (error) {
313
+ throw new Error(
314
+ getString(error, "message") || "Codex app-server request failed.",
315
+ );
316
+ }
317
+
318
+ return message;
319
+ }
320
+ };
321
+ const ensureConnection = async () => {
322
+ if (state) {
323
+ return state;
324
+ }
325
+
326
+ const connection = connect();
327
+ const nextState: CodexAppServerState = {
328
+ connection,
329
+ messages: connection.messages[Symbol.asyncIterator](),
330
+ };
331
+ state = nextState;
332
+
333
+ try {
334
+ const initializeRequestId = sendRequest(nextState, "initialize", {
335
+ clientInfo,
336
+ capabilities: { experimentalApi: true },
337
+ });
338
+ await readResponse(nextState, initializeRequestId, initializationMessage);
339
+ sendNotification(nextState, "initialized", {});
340
+
341
+ return nextState;
342
+ } catch (error) {
343
+ await close();
344
+ throw error;
345
+ }
346
+ };
347
+
348
+ return {
349
+ close,
350
+ async *stream(request: CodexAppServerRequest) {
351
+ const release = await acquireTurn();
352
+ let turnCompleted = false;
353
+ let turnStarted = false;
354
+
355
+ try {
356
+ const currentState = await ensureConnection();
357
+
358
+ const threadRequestId = await sendRequestAndResetOnError(
359
+ currentState,
360
+ request.threadId ? "thread/resume" : "thread/start",
361
+ {
362
+ ...(model ? { model } : {}),
363
+ ...(request.threadId ? { threadId: request.threadId } : {}),
364
+ ...(request.workingDirectory
365
+ ? { cwd: request.workingDirectory }
366
+ : {}),
367
+ },
368
+ );
369
+ const threadMessage = await readResponse(
370
+ currentState,
371
+ threadRequestId,
372
+ "Codex app-server closed before starting a turn.",
373
+ );
374
+ const threadId = getThreadId(threadMessage);
375
+ if (!threadId) {
376
+ throw new Error("Codex app-server closed before starting a turn.");
377
+ }
378
+
379
+ await request.onThreadId?.(threadId);
380
+ const pendingMessages: unknown[] = [];
381
+ const turnRequestId = await sendRequestAndResetOnError(
382
+ currentState,
383
+ "turn/start",
384
+ {
385
+ threadId,
386
+ input: [{ type: "text", text: request.input }],
387
+ },
388
+ );
389
+ turnStarted = true;
390
+ const turnMessage = await readResponse(
391
+ currentState,
392
+ turnRequestId,
393
+ incompleteTurnMessage,
394
+ pendingMessages,
395
+ );
396
+ const turnId = getResultTurnId(turnMessage);
397
+ if (!turnId) {
398
+ throw new Error(incompleteTurnMessage);
399
+ }
400
+
401
+ while (true) {
402
+ const message =
403
+ pendingMessages.length > 0
404
+ ? pendingMessages.shift()
405
+ : await readMessage(currentState);
406
+
407
+ if (message === undefined) {
408
+ throw new Error(incompleteTurnMessage);
409
+ }
410
+
411
+ if (isServerRequest(message)) {
412
+ await sendUnsupportedRequestResponse(currentState, message.id);
413
+ continue;
414
+ }
415
+
416
+ const error = getResponseError(message);
417
+ if (error) {
418
+ throw new Error(
419
+ getString(error, "message") || "Codex app-server request failed.",
420
+ );
421
+ }
422
+
423
+ const delta = getAgentMessageDelta(message, turnId);
424
+ if (delta) {
425
+ yield delta;
426
+ }
427
+
428
+ if (isTurnCompleted(message, turnId)) {
429
+ turnCompleted = true;
430
+ const errorMessage = getCompletedTurnErrorMessage(message);
431
+ if (errorMessage) {
432
+ throw new Error(errorMessage);
433
+ }
434
+ return;
435
+ }
436
+ }
437
+ } finally {
438
+ try {
439
+ if (turnStarted && !turnCompleted) {
440
+ await close();
441
+ }
442
+ } finally {
443
+ release();
444
+ }
445
+ }
446
+ },
447
+ };
448
+ }
449
+
450
+ export const codexAppServer = { create };
@@ -0,0 +1,77 @@
1
+ import type { WebhookOptions } from "chat";
2
+ import { type Context, Hono } from "hono";
3
+
4
+ type WebhookHandler = (
5
+ request: Request,
6
+ options?: WebhookOptions,
7
+ ) => Promise<Response>;
8
+
9
+ type GatewayBot = {
10
+ webhooks: Partial<Record<"telegram" | "zalo", WebhookHandler>>;
11
+ };
12
+
13
+ type GatewayBots = Record<string, GatewayBot>;
14
+
15
+ type GatewayServerOptions = {
16
+ bots: GatewayBots;
17
+ port?: number;
18
+ };
19
+
20
+ const platformNames = ["telegram", "zalo"] as const;
21
+
22
+ type PlatformName = (typeof platformNames)[number];
23
+
24
+ function isPlatformName(value: string): value is PlatformName {
25
+ return platformNames.some((platformName) => platformName === value);
26
+ }
27
+
28
+ function routeWebhook(
29
+ context: Context,
30
+ bot: GatewayBot | undefined,
31
+ platformName: PlatformName,
32
+ ) {
33
+ if (!bot) {
34
+ return context.text("agent is not configured.", 404);
35
+ }
36
+
37
+ const webhook = bot.webhooks[platformName];
38
+
39
+ if (!webhook) {
40
+ return context.text(`${platformName} is not configured.`, 404);
41
+ }
42
+
43
+ return webhook(context.req.raw, {
44
+ waitUntil: (task) => {
45
+ task.catch((error: unknown) => {
46
+ console.error(error);
47
+ });
48
+ },
49
+ });
50
+ }
51
+
52
+ function create(options: GatewayServerOptions) {
53
+ const { bots, port = 42240 } = options;
54
+ const hono = new Hono();
55
+
56
+ hono.get("/health", (context) => context.text("OK"));
57
+
58
+ hono.all("/webhooks/:agentId/:platformName", (context) => {
59
+ const platformName = context.req.param("platformName");
60
+ if (!isPlatformName(platformName)) {
61
+ return context.text(`${platformName} is not configured.`, 404);
62
+ }
63
+
64
+ return routeWebhook(
65
+ context,
66
+ bots[context.req.param("agentId")],
67
+ platformName,
68
+ );
69
+ });
70
+
71
+ return {
72
+ port,
73
+ fetch: hono.fetch,
74
+ };
75
+ }
76
+
77
+ export const codexGatewayServer = { create };
package/lib/main.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { agentConfigs } from "~/lib/agent-configs";
2
+ import { chatBot } from "~/lib/chat-bot";
3
+ import { codexGatewayServer } from "~/lib/codex-gateway-server";
4
+
5
+ const agents = await agentConfigs.load();
6
+ const bots = chatBot.createMany({ agents });
7
+
8
+ await Promise.all(Object.values(bots).map((bot) => bot.initialize()));
9
+
10
+ export default codexGatewayServer.create({
11
+ bots,
12
+ });
package/lib/pg-pool.ts ADDED
@@ -0,0 +1,7 @@
1
+ import pg from "pg";
2
+ import { pgURL } from "~/lib/pg-url";
3
+
4
+ export const pgPool = new pg.Pool({
5
+ connectionString: pgURL,
6
+ options: "-c search_path=codex_gateway",
7
+ });
@@ -0,0 +1,35 @@
1
+ import { createPostgresState } from "@chat-adapter/state-pg";
2
+ import { pgPool } from "~/lib/pg-pool";
3
+
4
+ type PgStateOptions = {
5
+ keyPrefix: string;
6
+ };
7
+
8
+ let schemaPromise: Promise<unknown> | undefined;
9
+
10
+ function ensureSchema() {
11
+ schemaPromise ||= pgPool
12
+ .query("CREATE SCHEMA IF NOT EXISTS codex_gateway")
13
+ .catch((error: unknown) => {
14
+ schemaPromise = undefined;
15
+ throw error;
16
+ });
17
+ return schemaPromise;
18
+ }
19
+
20
+ function create(options: PgStateOptions) {
21
+ const state = createPostgresState({
22
+ client: pgPool,
23
+ keyPrefix: options.keyPrefix,
24
+ });
25
+ const pgConnect = state.connect.bind(state);
26
+
27
+ state.connect = async (...args: Parameters<typeof state.connect>) => {
28
+ await ensureSchema();
29
+ await pgConnect(...args);
30
+ };
31
+
32
+ return state;
33
+ }
34
+
35
+ export const pgState = { create };
package/lib/pg-url.ts ADDED
@@ -0,0 +1,2 @@
1
+ export const pgURL =
2
+ Bun.env.PG_URL || "postgres://postgres:postgres@localhost:42340/postgres";