@poncho-ai/harness 0.30.0 → 0.31.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.30.0",
3
+ "version": "0.31.1",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,7 +34,7 @@
34
34
  "redis": "^5.10.0",
35
35
  "yaml": "^2.4.0",
36
36
  "zod": "^3.22.0",
37
- "@poncho-ai/sdk": "1.6.3"
37
+ "@poncho-ai/sdk": "1.7.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/mustache": "^4.2.6",
@@ -170,7 +170,7 @@ export const parseAgentMarkdown = (content: string): ParsedAgent => {
170
170
  throw new Error("Invalid AGENT.md: frontmatter requires a non-empty `name`.");
171
171
  }
172
172
 
173
- const KNOWN_PROVIDERS = new Set(["anthropic", "openai"]);
173
+ const KNOWN_PROVIDERS = new Set(["anthropic", "openai", "openai-codex"]);
174
174
  const splitProviderPrefix = (raw: string): { provider?: string; name: string } => {
175
175
  const slashIdx = raw.indexOf("/");
176
176
  if (slashIdx > 0) {
package/src/config.ts CHANGED
@@ -100,6 +100,13 @@ export interface PonchoConfig extends McpConfig {
100
100
  storage?: StorageConfig;
101
101
  providers?: {
102
102
  openai?: { apiKeyEnv?: string };
103
+ openaiCodex?: {
104
+ refreshTokenEnv?: string;
105
+ accessTokenEnv?: string;
106
+ accessTokenExpiresAtEnv?: string;
107
+ accountIdEnv?: string;
108
+ authFilePathEnv?: string;
109
+ };
103
110
  anthropic?: { apiKeyEnv?: string };
104
111
  };
105
112
  telemetry?: {
package/src/harness.ts CHANGED
@@ -451,6 +451,8 @@ export default {
451
451
  providers: {
452
452
  anthropic: { apiKeyEnv: "ANTHROPIC_API_KEY" },
453
453
  openai: { apiKeyEnv: "OPENAI_API_KEY" },
454
+ // openai-codex provider reads OAuth tokens from env vars by default:
455
+ // openaiCodex: { refreshTokenEnv: "OPENAI_CODEX_REFRESH_TOKEN", accountIdEnv: "OPENAI_CODEX_ACCOUNT_ID" },
454
456
  },
455
457
  auth: {
456
458
  required: true,
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export * from "./latitude-capture.js";
8
8
  export * from "./memory.js";
9
9
  export * from "./mcp.js";
10
10
  export * from "./model-factory.js";
11
+ export * from "./openai-codex-auth.js";
11
12
  export * from "./schema-converter.js";
12
13
  export * from "./search-tools.js";
13
14
  export * from "./skill-context.js";
@@ -1,6 +1,10 @@
1
1
  import { createOpenAI } from "@ai-sdk/openai";
2
2
  import { createAnthropic } from "@ai-sdk/anthropic";
3
3
  import type { LanguageModel } from "ai";
4
+ import {
5
+ getOpenAICodexAccessToken,
6
+ type OpenAICodexAuthConfig,
7
+ } from "./openai-codex-auth.js";
4
8
 
5
9
  export type ModelProviderFactory = (modelName: string) => LanguageModel;
6
10
 
@@ -30,6 +34,51 @@ const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
30
34
  };
31
35
 
32
36
  const DEFAULT_CONTEXT_WINDOW = 200_000;
37
+ const OPENAI_CODEX_DEFAULT_INSTRUCTIONS =
38
+ "You are Codex, based on GPT-5. You are running as a coding agent in Poncho.";
39
+ const OPENAI_CODEX_RESPONSES_URL =
40
+ process.env.OPENAI_CODEX_RESPONSES_URL ?? "https://chatgpt.com/backend-api/codex/responses";
41
+
42
+ const extractSystemInstructionFromInput = (input: unknown): string | undefined => {
43
+ if (!Array.isArray(input)) return undefined;
44
+ for (const message of input) {
45
+ if (!message || typeof message !== "object") continue;
46
+ const candidate = message as { role?: unknown; content?: unknown };
47
+ if (candidate.role !== "system") continue;
48
+ if (typeof candidate.content === "string" && candidate.content.trim().length > 0) {
49
+ return candidate.content;
50
+ }
51
+ if (Array.isArray(candidate.content)) {
52
+ const textParts = candidate.content
53
+ .map((part) => {
54
+ if (!part || typeof part !== "object") return "";
55
+ const p = part as { text?: unknown };
56
+ return typeof p.text === "string" ? p.text : "";
57
+ })
58
+ .filter((text) => text.trim().length > 0);
59
+ if (textParts.length > 0) {
60
+ return textParts.join("\n");
61
+ }
62
+ }
63
+ }
64
+ return undefined;
65
+ };
66
+
67
+ const normalizeToolParameterSchemas = (tools: unknown): void => {
68
+ if (!Array.isArray(tools)) return;
69
+ for (const tool of tools) {
70
+ if (!tool || typeof tool !== "object") continue;
71
+ const entry = tool as { parameters?: unknown };
72
+ if (!entry.parameters || typeof entry.parameters !== "object") continue;
73
+ const schema = entry.parameters as {
74
+ type?: unknown;
75
+ properties?: unknown;
76
+ };
77
+ if (schema.type === "object" && (typeof schema.properties !== "object" || schema.properties === null)) {
78
+ schema.properties = {};
79
+ }
80
+ }
81
+ };
33
82
 
34
83
  /**
35
84
  * Returns the context window size (in tokens) for a given model name.
@@ -51,6 +100,7 @@ export const getModelContextWindow = (modelName: string): number => {
51
100
 
52
101
  export interface ProviderConfig {
53
102
  openai?: { apiKeyEnv?: string };
103
+ openaiCodex?: OpenAICodexAuthConfig;
54
104
  anthropic?: { apiKeyEnv?: string };
55
105
  }
56
106
 
@@ -62,6 +112,76 @@ export interface ProviderConfig {
62
112
  export const createModelProvider = (provider?: string, config?: ProviderConfig): ModelProviderFactory => {
63
113
  const normalized = (provider ?? "anthropic").toLowerCase();
64
114
 
115
+ if (normalized === "openai-codex") {
116
+ const openai = createOpenAI({
117
+ apiKey: "oauth-placeholder",
118
+ fetch: async (input, init) => {
119
+ const { accessToken, accountId } = await getOpenAICodexAccessToken(config?.openaiCodex);
120
+ const headers = new Headers(init?.headers);
121
+ headers.set("Authorization", `Bearer ${accessToken}`);
122
+ headers.set("originator", "poncho");
123
+ headers.set("User-Agent", "poncho/1.0");
124
+ if (accountId) {
125
+ headers.set("ChatGPT-Account-Id", accountId);
126
+ }
127
+ const originalUrl =
128
+ input instanceof URL
129
+ ? input.toString()
130
+ : typeof input === "string"
131
+ ? input
132
+ : input.url;
133
+ const parsed = new URL(originalUrl);
134
+ const shouldRewrite =
135
+ parsed.pathname.includes("/v1/responses") ||
136
+ parsed.pathname.includes("/chat/completions");
137
+ const targetUrl = shouldRewrite
138
+ ? OPENAI_CODEX_RESPONSES_URL
139
+ : originalUrl;
140
+ let body = init?.body;
141
+ if (
142
+ shouldRewrite &&
143
+ typeof body === "string" &&
144
+ headers.get("Content-Type")?.includes("application/json")
145
+ ) {
146
+ try {
147
+ const payload = JSON.parse(body) as {
148
+ instructions?: unknown;
149
+ input?: unknown;
150
+ store?: unknown;
151
+ tools?: unknown;
152
+ };
153
+ if (typeof payload.instructions !== "string" || payload.instructions.trim() === "") {
154
+ payload.instructions =
155
+ extractSystemInstructionFromInput(payload.input) ??
156
+ OPENAI_CODEX_DEFAULT_INSTRUCTIONS;
157
+ }
158
+ normalizeToolParameterSchemas(payload.tools);
159
+ // Codex endpoint requires store=false explicitly.
160
+ payload.store = false;
161
+ body = JSON.stringify(payload);
162
+ } catch {
163
+ // Keep original body if parsing fails.
164
+ }
165
+ }
166
+ try {
167
+ return await fetch(targetUrl, { ...init, headers, body });
168
+ } catch (error) {
169
+ const message = error instanceof Error ? error.message : String(error);
170
+ if (
171
+ shouldRewrite &&
172
+ targetUrl.includes("chatgpt.com") &&
173
+ message.includes("ENOTFOUND chatgpt.com")
174
+ ) {
175
+ // Some networks block/override chatgpt.com DNS; retry on the SDK's original URL.
176
+ return fetch(originalUrl, { ...init, headers, body });
177
+ }
178
+ throw error;
179
+ }
180
+ },
181
+ });
182
+ return (modelName: string) => openai(modelName);
183
+ }
184
+
65
185
  if (normalized === "openai") {
66
186
  const apiKeyEnv = config?.openai?.apiKeyEnv ?? "OPENAI_API_KEY";
67
187
  const openai = createOpenAI({
@@ -0,0 +1,362 @@
1
+ import { homedir } from "node:os";
2
+ import { dirname, resolve } from "node:path";
3
+ import { mkdir, readFile, chmod, writeFile, rm } from "node:fs/promises";
4
+
5
+ export const OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
6
+ const OPENAI_AUTH_ISSUER = "https://auth.openai.com";
7
+ const REFRESH_TOKEN_GRACE_MS = 5 * 60 * 1000;
8
+ const DEVICE_POLLING_SAFETY_MARGIN_MS = 3000;
9
+ const DEVICE_FLOW_TIMEOUT_MS = 10 * 60 * 1000;
10
+ const REQUIRED_SCOPES = [
11
+ "openid",
12
+ "profile",
13
+ "email",
14
+ "offline_access",
15
+ "api.responses.write",
16
+ "model.request",
17
+ "api.model.read",
18
+ ];
19
+
20
+ export interface OpenAICodexAuthConfig {
21
+ refreshTokenEnv?: string;
22
+ accessTokenEnv?: string;
23
+ accessTokenExpiresAtEnv?: string;
24
+ accountIdEnv?: string;
25
+ authFilePathEnv?: string;
26
+ }
27
+
28
+ export interface OpenAICodexSession {
29
+ refreshToken: string;
30
+ accessToken?: string;
31
+ accessTokenExpiresAt?: number;
32
+ accountId?: string;
33
+ }
34
+
35
+ interface OpenAICodexStoredSession extends OpenAICodexSession {
36
+ updatedAt: string;
37
+ }
38
+
39
+ type SessionSource = "env" | "file";
40
+
41
+ const defaultedConfig = (
42
+ config?: OpenAICodexAuthConfig,
43
+ ): Required<OpenAICodexAuthConfig> => ({
44
+ refreshTokenEnv: config?.refreshTokenEnv ?? "OPENAI_CODEX_REFRESH_TOKEN",
45
+ accessTokenEnv: config?.accessTokenEnv ?? "OPENAI_CODEX_ACCESS_TOKEN",
46
+ accessTokenExpiresAtEnv:
47
+ config?.accessTokenExpiresAtEnv ?? "OPENAI_CODEX_ACCESS_TOKEN_EXPIRES_AT",
48
+ accountIdEnv: config?.accountIdEnv ?? "OPENAI_CODEX_ACCOUNT_ID",
49
+ authFilePathEnv: config?.authFilePathEnv ?? "OPENAI_CODEX_AUTH_FILE",
50
+ });
51
+
52
+ const parseEpochMillis = (value: string | undefined): number | undefined => {
53
+ if (!value) return undefined;
54
+ const parsed = Number.parseInt(value, 10);
55
+ if (Number.isNaN(parsed) || parsed <= 0) return undefined;
56
+ return parsed;
57
+ };
58
+
59
+ export const getOpenAICodexAuthFilePath = (config?: OpenAICodexAuthConfig): string => {
60
+ const env = defaultedConfig(config);
61
+ const fromEnv = process.env[env.authFilePathEnv];
62
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
63
+ return resolve(fromEnv);
64
+ }
65
+ return resolve(homedir(), ".poncho", "auth", "openai-codex.json");
66
+ };
67
+
68
+ export const readOpenAICodexSession = async (
69
+ config?: OpenAICodexAuthConfig,
70
+ ): Promise<OpenAICodexSession | undefined> => {
71
+ const filePath = getOpenAICodexAuthFilePath(config);
72
+ try {
73
+ const content = await readFile(filePath, "utf8");
74
+ const parsed = JSON.parse(content) as Partial<OpenAICodexStoredSession>;
75
+ if (typeof parsed.refreshToken !== "string" || parsed.refreshToken.length === 0) {
76
+ return undefined;
77
+ }
78
+ return {
79
+ refreshToken: parsed.refreshToken,
80
+ accessToken:
81
+ typeof parsed.accessToken === "string" && parsed.accessToken.length > 0
82
+ ? parsed.accessToken
83
+ : undefined,
84
+ accessTokenExpiresAt:
85
+ typeof parsed.accessTokenExpiresAt === "number" && parsed.accessTokenExpiresAt > 0
86
+ ? parsed.accessTokenExpiresAt
87
+ : undefined,
88
+ accountId:
89
+ typeof parsed.accountId === "string" && parsed.accountId.length > 0
90
+ ? parsed.accountId
91
+ : undefined,
92
+ };
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ };
97
+
98
+ export const writeOpenAICodexSession = async (
99
+ session: OpenAICodexSession,
100
+ config?: OpenAICodexAuthConfig,
101
+ ): Promise<void> => {
102
+ const filePath = getOpenAICodexAuthFilePath(config);
103
+ await mkdir(dirname(filePath), { recursive: true });
104
+ const payload: OpenAICodexStoredSession = {
105
+ ...session,
106
+ updatedAt: new Date().toISOString(),
107
+ };
108
+ await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
109
+ await chmod(filePath, 0o600);
110
+ };
111
+
112
+ export const deleteOpenAICodexSession = async (
113
+ config?: OpenAICodexAuthConfig,
114
+ ): Promise<void> => {
115
+ const filePath = getOpenAICodexAuthFilePath(config);
116
+ await rm(filePath, { force: true });
117
+ };
118
+
119
+ interface TokenResponse {
120
+ access_token: string;
121
+ refresh_token?: string;
122
+ expires_in?: number;
123
+ id_token?: string;
124
+ }
125
+
126
+ interface DeviceStartResponse {
127
+ device_auth_id: string;
128
+ user_code: string;
129
+ interval?: string | number;
130
+ }
131
+
132
+ interface DeviceTokenResponse {
133
+ authorization_code: string;
134
+ code_verifier: string;
135
+ }
136
+
137
+ const parseAccountIdFromJwt = (token: string | undefined): string | undefined => {
138
+ if (!token) return undefined;
139
+ const parts = token.split(".");
140
+ if (parts.length !== 3) return undefined;
141
+ try {
142
+ const claims = JSON.parse(Buffer.from(parts[1] ?? "", "base64url").toString("utf8")) as {
143
+ chatgpt_account_id?: string;
144
+ "https://api.openai.com/auth"?: {
145
+ chatgpt_account_id?: string;
146
+ };
147
+ organizations?: Array<{ id?: string }>;
148
+ };
149
+ return (
150
+ claims.chatgpt_account_id ??
151
+ claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
152
+ claims.organizations?.[0]?.id
153
+ );
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ };
158
+
159
+ const exchangeRefreshToken = async (refreshToken: string): Promise<TokenResponse> => {
160
+ const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
161
+ method: "POST",
162
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
163
+ body: new URLSearchParams({
164
+ grant_type: "refresh_token",
165
+ refresh_token: refreshToken,
166
+ client_id: OPENAI_CODEX_CLIENT_ID,
167
+ }).toString(),
168
+ });
169
+
170
+ if (!response.ok) {
171
+ const body = await response.text();
172
+ throw new Error(
173
+ `OpenAI Codex token refresh failed (${response.status}). Re-run \`poncho auth login --provider openai-codex --device\`, export the new token, and update deployment secrets. Details: ${body.slice(0, 240)}`,
174
+ );
175
+ }
176
+
177
+ return (await response.json()) as TokenResponse;
178
+ };
179
+
180
+ const exchangeAuthorizationCode = async (
181
+ code: string,
182
+ codeVerifier: string,
183
+ ): Promise<TokenResponse> => {
184
+ const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
185
+ method: "POST",
186
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
187
+ body: new URLSearchParams({
188
+ grant_type: "authorization_code",
189
+ code,
190
+ redirect_uri: `${OPENAI_AUTH_ISSUER}/deviceauth/callback`,
191
+ client_id: OPENAI_CODEX_CLIENT_ID,
192
+ code_verifier: codeVerifier,
193
+ }).toString(),
194
+ });
195
+ if (!response.ok) {
196
+ const body = await response.text();
197
+ throw new Error(
198
+ `OpenAI Codex device token exchange failed (${response.status}): ${body.slice(0, 240)}`,
199
+ );
200
+ }
201
+ return (await response.json()) as TokenResponse;
202
+ };
203
+
204
+ const readSessionFromEnv = (config?: OpenAICodexAuthConfig): OpenAICodexSession | undefined => {
205
+ const env = defaultedConfig(config);
206
+ const refreshToken = process.env[env.refreshTokenEnv];
207
+ if (!refreshToken || refreshToken.trim().length === 0) return undefined;
208
+ return {
209
+ refreshToken: refreshToken.trim(),
210
+ accessToken: process.env[env.accessTokenEnv]?.trim() || undefined,
211
+ accessTokenExpiresAt: parseEpochMillis(process.env[env.accessTokenExpiresAtEnv]),
212
+ accountId: process.env[env.accountIdEnv]?.trim() || undefined,
213
+ };
214
+ };
215
+
216
+ const shouldRefresh = (expiresAt: number | undefined): boolean =>
217
+ !expiresAt || Date.now() + REFRESH_TOKEN_GRACE_MS >= expiresAt;
218
+
219
+ let runtimeCachedSession: OpenAICodexSession | undefined;
220
+
221
+ const readSession = async (
222
+ config?: OpenAICodexAuthConfig,
223
+ ): Promise<{ session: OpenAICodexSession; source: SessionSource }> => {
224
+ const envSession = readSessionFromEnv(config);
225
+ if (envSession) return { session: envSession, source: "env" };
226
+
227
+ if (runtimeCachedSession) {
228
+ return { session: runtimeCachedSession, source: "file" };
229
+ }
230
+
231
+ const fileSession = await readOpenAICodexSession(config);
232
+ if (!fileSession) {
233
+ throw new Error(
234
+ "OpenAI Codex credentials not found. Run `poncho auth login --provider openai-codex --device` locally, or set OPENAI_CODEX_REFRESH_TOKEN in your environment.",
235
+ );
236
+ }
237
+ runtimeCachedSession = fileSession;
238
+ return { session: fileSession, source: "file" };
239
+ };
240
+
241
+ export const getOpenAICodexAccessToken = async (
242
+ config?: OpenAICodexAuthConfig,
243
+ ): Promise<{ accessToken: string; accountId?: string }> => {
244
+ const { session, source } = await readSession(config);
245
+ if (session.accessToken && !shouldRefresh(session.accessTokenExpiresAt)) {
246
+ return { accessToken: session.accessToken, accountId: session.accountId };
247
+ }
248
+
249
+ const refreshed = await exchangeRefreshToken(session.refreshToken);
250
+ const nextSession: OpenAICodexSession = {
251
+ refreshToken: refreshed.refresh_token ?? session.refreshToken,
252
+ accessToken: refreshed.access_token,
253
+ accessTokenExpiresAt: Date.now() + (refreshed.expires_in ?? 3600) * 1000,
254
+ accountId:
255
+ session.accountId ??
256
+ parseAccountIdFromJwt(refreshed.id_token) ??
257
+ parseAccountIdFromJwt(refreshed.access_token),
258
+ };
259
+
260
+ runtimeCachedSession = nextSession;
261
+ if (source === "file") {
262
+ await writeOpenAICodexSession(nextSession, config);
263
+ }
264
+
265
+ return {
266
+ accessToken: nextSession.accessToken!,
267
+ accountId: nextSession.accountId,
268
+ };
269
+ };
270
+
271
+ export interface OpenAICodexDeviceAuthRequest {
272
+ deviceAuthId: string;
273
+ userCode: string;
274
+ verificationUrl: string;
275
+ intervalMs: number;
276
+ }
277
+
278
+ export const getOpenAICodexRequiredScopes = (): string[] => [...REQUIRED_SCOPES];
279
+
280
+ export const startOpenAICodexDeviceAuth = async (): Promise<OpenAICodexDeviceAuthRequest> => {
281
+ const response = await fetch(`${OPENAI_AUTH_ISSUER}/api/accounts/deviceauth/usercode`, {
282
+ method: "POST",
283
+ headers: {
284
+ "Content-Type": "application/json",
285
+ "User-Agent": "poncho/1.0",
286
+ },
287
+ body: JSON.stringify({
288
+ client_id: OPENAI_CODEX_CLIENT_ID,
289
+ scope: REQUIRED_SCOPES.join(" "),
290
+ }),
291
+ });
292
+ if (!response.ok) {
293
+ const body = await response.text();
294
+ throw new Error(
295
+ `OpenAI Codex device authorization start failed (${response.status}): ${body.slice(0, 240)}`,
296
+ );
297
+ }
298
+ const data = (await response.json()) as DeviceStartResponse;
299
+ const intervalRaw =
300
+ typeof data.interval === "number"
301
+ ? data.interval
302
+ : Number.parseInt(String(data.interval ?? "5"), 10);
303
+ const intervalMs = Math.max(Number.isNaN(intervalRaw) ? 5 : intervalRaw, 1) * 1000;
304
+ return {
305
+ deviceAuthId: data.device_auth_id,
306
+ userCode: data.user_code,
307
+ verificationUrl: `${OPENAI_AUTH_ISSUER}/codex/device`,
308
+ intervalMs,
309
+ };
310
+ };
311
+
312
+ export const completeOpenAICodexDeviceAuth = async (
313
+ request: OpenAICodexDeviceAuthRequest,
314
+ ): Promise<OpenAICodexSession> => {
315
+ const startedAt = Date.now();
316
+ while (Date.now() - startedAt < DEVICE_FLOW_TIMEOUT_MS) {
317
+ const response = await fetch(`${OPENAI_AUTH_ISSUER}/api/accounts/deviceauth/token`, {
318
+ method: "POST",
319
+ headers: {
320
+ "Content-Type": "application/json",
321
+ "User-Agent": "poncho/1.0",
322
+ },
323
+ body: JSON.stringify({
324
+ device_auth_id: request.deviceAuthId,
325
+ user_code: request.userCode,
326
+ }),
327
+ });
328
+ if (response.ok) {
329
+ const data = (await response.json()) as DeviceTokenResponse;
330
+ const tokens = await exchangeAuthorizationCode(
331
+ data.authorization_code,
332
+ data.code_verifier,
333
+ );
334
+ if (!tokens.refresh_token || tokens.refresh_token.length === 0) {
335
+ throw new Error("OpenAI Codex device auth succeeded but no refresh token was returned.");
336
+ }
337
+ return {
338
+ refreshToken: tokens.refresh_token,
339
+ accessToken: tokens.access_token,
340
+ accessTokenExpiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000,
341
+ accountId:
342
+ parseAccountIdFromJwt(tokens.id_token) ??
343
+ parseAccountIdFromJwt(tokens.access_token),
344
+ };
345
+ }
346
+
347
+ if (response.status !== 403 && response.status !== 404) {
348
+ const body = await response.text();
349
+ throw new Error(
350
+ `OpenAI Codex device authorization polling failed (${response.status}): ${body.slice(0, 240)}`,
351
+ );
352
+ }
353
+
354
+ await new Promise((resolveWait) => {
355
+ setTimeout(resolveWait, request.intervalMs + DEVICE_POLLING_SAFETY_MARGIN_MS);
356
+ });
357
+ }
358
+
359
+ throw new Error(
360
+ "OpenAI Codex device authorization timed out. Re-run `poncho auth login --provider openai-codex --device`.",
361
+ );
362
+ };
@@ -20,6 +20,18 @@ Working dir: {{runtime.workingDir}}
20
20
  expect(parsed.body).toContain("# Hello");
21
21
  });
22
22
 
23
+ it("parses provider prefix from model name for openai-codex", () => {
24
+ const parsed = parseAgentMarkdown(`---
25
+ name: codex-agent
26
+ model: openai-codex/gpt-5.3-codex
27
+ ---
28
+
29
+ # Agent
30
+ `);
31
+ expect(parsed.frontmatter.model?.provider).toBe("openai-codex");
32
+ expect(parsed.frontmatter.model?.name).toBe("gpt-5.3-codex");
33
+ });
34
+
23
35
  it("renders mustache runtime and parameter context", () => {
24
36
  const parsed = parseAgentMarkdown(`---
25
37
  name: test-agent
@@ -17,7 +17,7 @@ describe("model factory", () => {
17
17
 
18
18
  const model = provider("claude-3-opus-20240229");
19
19
  expect(model).toBeDefined();
20
- expect((model as { provider: string }).provider).toMatch(/^anthropic\./);
20
+ expect((model as { provider: string }).provider).toMatch(/^anthropic(?:\.|$)/);
21
21
  });
22
22
 
23
23
  it("defaults to Anthropic when no provider specified", () => {
@@ -26,7 +26,7 @@ describe("model factory", () => {
26
26
 
27
27
  const model = provider("claude-3-opus-20240229");
28
28
  expect(model).toBeDefined();
29
- expect((model as { provider: string }).provider).toMatch(/^anthropic\./);
29
+ expect((model as { provider: string }).provider).toMatch(/^anthropic(?:\.|$)/);
30
30
  });
31
31
 
32
32
  it("normalizes provider names to lowercase", () => {
@@ -36,4 +36,14 @@ describe("model factory", () => {
36
36
  const model = provider("gpt-4");
37
37
  expect((model as { provider: string }).provider).toBe("openai.responses");
38
38
  });
39
+
40
+ it("creates a function for OpenAI Codex provider", () => {
41
+ process.env.OPENAI_CODEX_REFRESH_TOKEN = "test-refresh-token";
42
+ const provider = createModelProvider("openai-codex");
43
+ expect(provider).toBeInstanceOf(Function);
44
+
45
+ const model = provider("gpt-5.3-codex");
46
+ expect(model).toBeDefined();
47
+ expect((model as { provider: string }).provider).toBe("openai.responses");
48
+ });
39
49
  });