← 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
}