Skip to content
← Public packages

@kody/codex

Create and manage OpenAI Agents API (Codex harness) cloud agent sessions.

src/client.ts

332 lines · 9.3 KB · TypeScript
/**
 * Shared OpenAI Agents API (Codex harness) REST helpers.
 * Auth is secret-backed via `{{secret:name}}` placeholders — never raw keys.
 * Always send `OpenAI-Beta: agents=v1`. Isolate-safe: fetch only, no SDK.
 */

export const API_HOST = 'api.openai.com'
export const API_BASE = `https://${API_HOST}/v1`
export const BETA_HEADER = 'agents=v1'
export const DEFAULT_API_KEY_SECRET = 'openaiApiKey'

/** Placeholder model from official docs — replace with a project-available model. */
export const DOCS_DEFAULT_MODEL = 'gpt-6-astra'

export const DOCS_OVERVIEW =
	'https://developers.openai.com/api/docs/guides/agents-api/overview'
export const DOCS_QUICKSTART =
	'https://developers.openai.com/api/docs/guides/agents-api/quickstart'
export const DOCS_SESSIONS =
	'https://developers.openai.com/api/docs/guides/agents-api/sessions'
export const DOCS_MANAGE =
	'https://developers.openai.com/api/docs/guides/agents-api/sessions/manage'
export const DOCS_EVENTS =
	'https://developers.openai.com/api/docs/guides/agents-api/sessions/events'

export const KEY_PERMISSIONS = [
	'api.agents.read',
	'api.agents.write',
	'api.responses.write',
] as const

export const TOKEN_SETUP_URL =
	'https://kody.codes/account/secrets/new?name=openaiApiKey&description=OpenAI%20API%20key%20with%20api.agents.read%2C%20api.agents.write%2C%20api.responses.write&allowedHosts=api.openai.com&scope=user'

export const LIMITATION_NOTE =
	'This package drives the OpenAI Agents API (POST /v1/agents/sessions). It does NOT remote-control existing chatgpt.com/codex web threads or `codex cloud` CLI chats — those use a different ChatGPT backend.'

const SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/

export type AuthOptions = {
	/**
	 * Alternate Kody user secret **name** only (not a key value).
	 * Defaults to `openaiApiKey`.
	 */
	apiKeySecret?: string
}

export class AgentsApiError extends Error {
	status: number
	details: unknown
	setupUrl: string

	constructor(
		message: string,
		input: { status?: number; details?: unknown; setupUrl?: string } = {},
	) {
		super(message)
		this.name = 'AgentsApiError'
		this.status = input.status ?? 0
		this.details = input.details ?? null
		this.setupUrl = input.setupUrl ?? TOKEN_SETUP_URL
	}
}

export function clean(value: unknown): string {
	return String(value ?? '').trim()
}

export function resolveApiKeySecretName(options: AuthOptions = {}): string {
	const explicit = clean(options.apiKeySecret)
	if (explicit) {
		if (!SECRET_NAME.test(explicit)) {
			throw new Error(
				'apiKeySecret must be a simple Kody secret name (letters, numbers, underscore).',
			)
		}
		return explicit
	}
	return DEFAULT_API_KEY_SECRET
}

export function secretSetupUrl(secretName: string = DEFAULT_API_KEY_SECRET): string {
	if (secretName === DEFAULT_API_KEY_SECRET) return TOKEN_SETUP_URL
	return TOKEN_SETUP_URL.replace(
		'name=openaiApiKey',
		'name=' + encodeURIComponent(secretName),
	)
}

export function authorizationHeader(options: AuthOptions = {}): string {
	const name = resolveApiKeySecretName(options)
	return `Bearer {{secret:${name}}}`
}

export function agentsHeaders(
	options: AuthOptions & { accept?: string } = {},
): Record<string, string> {
	return {
		Accept: options.accept ?? 'application/json',
		Authorization: authorizationHeader(options),
		'OpenAI-Beta': BETA_HEADER,
		'User-Agent': 'kody-codex/1.0',
	}
}

export function pickAuth(params: AuthOptions = {}): AuthOptions {
	const out: AuthOptions = {}
	if (params.apiKeySecret !== undefined) out.apiKeySecret = params.apiKeySecret
	return out
}

export function requireSessionId(sessionId: unknown): string {
	const id = clean(sessionId)
	if (!id) {
		throw new AgentsApiError('sessionId is required (e.g. sess_…).', {
			status: 400,
		})
	}
	return id
}

/** Normalize create/input `input` to API shape: string or message array. */
export function normalizeUserInput(input: unknown): unknown {
	if (input === undefined || input === null) return undefined
	if (typeof input === 'string') return input
	if (Array.isArray(input)) return input
	if (typeof input === 'object') return input
	return String(input)
}

/** Build a simple user text message array for follow-up input events. */
export function userTextMessage(text: string) {
	return [
		{
			role: 'user' as const,
			content: [{ type: 'input_text' as const, text }],
		},
	]
}

export type AgentsApiJson = {
	error?: { message?: string; type?: string; code?: string }
	[key: string]: unknown
}

export type AgentsApiResponse = {
	ok: boolean
	status: number
	contentType: string
	kind: 'json' | 'text' | 'sse'
	body: unknown
	text?: string
	events?: unknown[]
}

/**
 * Call the Agents API. Paths are relative to `/v1` (e.g. `/agents/sessions`).
 * When `expectSse` is true, parse `text/event-stream` into `events` (bounded).
 */
export async function agentsFetch(
	path: string,
	options: AuthOptions & {
		method?: string
		query?: Record<string, string | number | boolean | null | undefined>
		body?: unknown
		expectSse?: boolean
		/** Max SSE data lines to collect (default 200). */
		maxSseEvents?: number
	} = {},
): Promise<AgentsApiResponse> {
	const method = clean(options.method || 'GET').toUpperCase() || 'GET'
	const url = new URL(
		path.startsWith('http') ? path : path.replace(/^\//, ''),
		API_BASE.endsWith('/') ? API_BASE : API_BASE + '/',
	)
	if (options.query) {
		for (const [key, value] of Object.entries(options.query)) {
			if (value === undefined || value === null) continue
			url.searchParams.set(key, String(value))
		}
	}

	const headers = agentsHeaders({
		...options,
		accept: options.expectSse ? 'text/event-stream' : 'application/json',
	})
	const init: RequestInit = { method, headers }
	if (options.body !== undefined && method !== 'GET' && method !== 'HEAD') {
		headers['Content-Type'] = 'application/json'
		init.body = JSON.stringify(options.body)
	}

	const response = await fetch(url.toString(), init)
	const contentType = (response.headers.get('content-type') || '').toLowerCase()
	const text = await response.text()

	const setup = secretSetupUrl(resolveApiKeySecretName(options))
	const authHint =
		response.status === 401 || response.status === 403
			? ` Save a Platform API key with ${KEY_PERMISSIONS.join(', ')} at ${setup}.`
			: ''

	if (
		options.expectSse ||
		contentType.includes('text/event-stream') ||
		(text.includes('data:') && contentType.includes('text/'))
	) {
		const events = parseSseDataEvents(text, options.maxSseEvents ?? 200)
		if (!response.ok) {
			throw new AgentsApiError(
				`Agents API SSE error HTTP ${response.status}.${authHint}`,
				{ status: response.status, details: { text: text.slice(0, 1000), events }, setupUrl: setup },
			)
		}
		return {
			ok: true,
			status: response.status,
			contentType: contentType || 'text/event-stream',
			kind: 'sse',
			body: events,
			text,
			events,
		}
	}

	if (!text.trim()) {
		if (!response.ok) {
			throw new AgentsApiError(`Agents API HTTP ${response.status} (empty body).${authHint}`, {
				status: response.status,
				setupUrl: setup,
			})
		}
		return {
			ok: true,
			status: response.status,
			contentType,
			kind: 'text',
			body: null,
			text: '',
		}
	}

	if (
		contentType.includes('application/json') ||
		text.trim().startsWith('{') ||
		text.trim().startsWith('[')
	) {
		let body: AgentsApiJson
		try {
			body = JSON.parse(text) as AgentsApiJson
		} catch {
			throw new AgentsApiError(
				`Agents API returned non-JSON (${response.status}).`,
				{ status: response.status, details: text.slice(0, 500), setupUrl: setup },
			)
		}
		if (!response.ok) {
			const errMsg =
				body.error?.message ||
				(typeof body.error === 'string' ? body.error : null) ||
				`HTTP ${response.status}`
			throw new AgentsApiError(`Agents API error: ${errMsg}.${authHint}`, {
				status: response.status,
				details: body,
				setupUrl: setup,
			})
		}
		return {
			ok: true,
			status: response.status,
			contentType: contentType || 'application/json',
			kind: 'json',
			body,
		}
	}

	if (!response.ok) {
		throw new AgentsApiError(`Agents API HTTP ${response.status}: ${text.slice(0, 300)}.${authHint}`, {
			status: response.status,
			details: text.slice(0, 1000),
			setupUrl: setup,
		})
	}

	return {
		ok: true,
		status: response.status,
		contentType,
		kind: 'text',
		body: text,
		text,
	}
}

/** Parse SSE `data:` lines into JSON objects when possible. */
export function parseSseDataEvents(raw: string, maxEvents: number): unknown[] {
	const events: unknown[] = []
	const lines = raw.split(/\r?\n/)
	for (const line of lines) {
		if (!line.startsWith('data:')) continue
		const payload = line.slice(5).trim()
		if (!payload || payload === '[DONE]') continue
		try {
			events.push(JSON.parse(payload))
		} catch {
			events.push({ raw: payload })
		}
		if (events.length >= maxEvents) break
	}
	return events
}

export function sessionIdFromCreateResult(body: unknown, events?: unknown[]): string | null {
	if (body && typeof body === 'object' && !Array.isArray(body)) {
		const o = body as Record<string, unknown>
		if (typeof o.id === 'string') return o.id
		if (typeof o.session_id === 'string') return o.session_id
	}
	if (Array.isArray(events)) {
		for (const ev of events) {
			if (!ev || typeof ev !== 'object') continue
			const e = ev as Record<string, unknown>
			if (typeof e.session_id === 'string') return e.session_id
			const session = e.session
			if (session && typeof session === 'object') {
				const sid = (session as { id?: unknown }).id
				if (typeof sid === 'string') return sid
			}
		}
	}
	return null
}