Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions agents/codelayer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,20 @@
"dependencies": {
"@ai-sdk/anthropic": "catalog:",
"@ai-sdk/openai": "catalog:",
"@aws/bedrock-token-generator": "catalog:",
"@aws-sdk/credential-providers": "catalog:",
"@humanlayer/agentlayer-core": "workspace:*",
"@humanlayer/agentlayer-filesystem": "workspace:*",
"@humanlayer/agentlayer-provider-auth": "workspace:*",
"@humanlayer/agentlayer-provider-github-copilot": "workspace:*",
"@humanlayer/agentlayer-provider-openai-codex": "workspace:*",
"@smithy/types": "catalog:",
"@smithy/config-resolver": "catalog:",
"@smithy/node-config-provider": "catalog:",
"ai": "catalog:",
"chalk": "^5.6.2",
"commander": "^14.0.3",
"smol-toml": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
Expand Down
168 changes: 168 additions & 0 deletions agents/codelayer/src/codex/bedrock-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import { getToken as generateBedrockToken } from '@aws/bedrock-token-generator'
import { fromIni, fromNodeProviderChain } from '@aws-sdk/credential-providers'
import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@smithy/types'

const TOKEN_LIFETIME_SECONDS = 12 * 60 * 60
const REFRESH_BUFFER_MS = 5 * 60 * 1000

export interface BedrockAuth {
getToken(): Promise<string>
invalidate(): void
}

export interface BedrockAuthDependencies {
now?: () => number
credentialProviderFactory?: (profile?: string) => AwsCredentialIdentityProvider
tokenGenerator?: (options: {
credentials: AwsCredentialIdentity
region: string
expiresInSeconds: number
}) => Promise<string>
}

export interface MakeBedrockAuthOptions extends BedrockAuthDependencies {
profile?: string
region: string
}

export type BedrockFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>

export class BedrockCredentialsUnavailableError extends Error {
constructor(profile?: string) {
super(
profile
? `AWS credentials for profile "${profile}" are unavailable or expired. Refresh the profile with your normal AWS login command, then retry this prompt. HumanLayer does not need to restart.`
: 'AWS credentials are unavailable or expired. Refresh them with your normal AWS login command, then retry this prompt. HumanLayer does not need to restart.',
)
this.name = 'BedrockCredentialsUnavailableError'
}
}

export function makeBedrockAuth(options: MakeBedrockAuthOptions): BedrockAuth {
const now = options.now ?? Date.now
const providerFactory = options.credentialProviderFactory ?? ((profile) =>
profile
? fromIni({ profile, clientConfig: { region: options.region } })
: fromNodeProviderChain({ clientConfig: { region: options.region } }))
const tokenGenerator = options.tokenGenerator ?? generateBedrockToken
let credentialProvider: AwsCredentialIdentityProvider | undefined
let token: string | undefined
let refreshAt: number | undefined
let refresh: Promise<string> | undefined
let generation = 0

const invalidate = () => {
generation++
credentialProvider = undefined
token = undefined
refreshAt = undefined
refresh = undefined
}

const performRefresh = async (refreshGeneration: number): Promise<string> => {
try {
const provider = credentialProvider ?? providerFactory(options.profile)
if (refreshGeneration === generation) credentialProvider = provider
const credentials = await provider()
const generatedAt = now()
const generatedToken = await tokenGenerator({
credentials,
region: options.region,
expiresInSeconds: TOKEN_LIFETIME_SECONDS,
})
const tokenExpiry = generatedAt + TOKEN_LIFETIME_SECONDS * 1000
const credentialExpiry = credentials.expiration?.getTime()
const effectiveExpiry = credentialExpiry === undefined
? tokenExpiry
: Math.min(tokenExpiry, credentialExpiry)
const remaining = Math.max(0, effectiveExpiry - generatedAt)
if (refreshGeneration !== generation) return getToken()
token = generatedToken
refreshAt = remaining <= REFRESH_BUFFER_MS
? generatedAt + remaining / 2
: effectiveExpiry - REFRESH_BUFFER_MS
return generatedToken
} catch {
if (refreshGeneration !== generation) return getToken()
invalidate()
throw new BedrockCredentialsUnavailableError(options.profile)
}
}

const getToken = async (): Promise<string> => {
if (token !== undefined && refreshAt !== undefined && now() < refreshAt) return token
if (refresh) return refresh
const refreshGeneration = generation
const nextRefresh = performRefresh(refreshGeneration)
refresh = nextRefresh
void nextRefresh.finally(() => {
if (refresh === nextRefresh) refresh = undefined
}).catch(() => {})
return nextRefresh
}

return {
getToken,
invalidate,
}
}

export async function isBedrockAuthenticationFailure(response: Response): Promise<boolean> {
if (response.status === 401) return true
if (response.status !== 403) return false
try {
const body = await readResponsePrefix(response, 16_384)
return ['ExpiredToken', 'UnrecognizedClientException', 'InvalidClientTokenId'].some((code) =>
body.includes(code))
} catch {
return false
}
}

async function readResponsePrefix(response: Response, maximumBytes: number): Promise<string> {
const body = response.clone().body
if (!body) return ''
const reader = body.getReader()
const decoder = new TextDecoder()
let result = ''
let bytesRead = 0
try {
while (bytesRead < maximumBytes) {
const { done, value } = await reader.read()
if (done) break
const remaining = maximumBytes - bytesRead
const chunk = value.byteLength > remaining ? value.subarray(0, remaining) : value
bytesRead += chunk.byteLength
result += decoder.decode(chunk, { stream: bytesRead < maximumBytes })
}
result += decoder.decode()
return result
} finally {
if (bytesRead >= maximumBytes) void reader.cancel().catch(() => {})
reader.releaseLock()
}
}

export async function fetchWithBedrockAuth(
auth: BedrockAuth,
fetch: BedrockFetch,
input: string | URL | Request,
init?: RequestInit,
): Promise<Response> {
const headers = new Headers(input instanceof Request ? input.headers : undefined)
new Headers(init?.headers).forEach((value, key) => headers.set(key, value))
const request = input instanceof Request
? new Request(input, { ...init, headers })
: new Request(input.toString(), { ...init, headers })
const attempts = [request.clone() as Request, request.clone() as Request]
const send = async (attempt: Request) => {
const headers = new Headers(attempt.headers)
headers.set('authorization', `Bearer ${await auth.getToken()}`)
return fetch(new Request(attempt, { headers }))
}

const first = await send(attempts[0]!)
if (!(await isBedrockAuthenticationFailure(first))) return first
auth.invalidate()
return send(attempts[1]!)
}
67 changes: 67 additions & 0 deletions agents/codelayer/src/codex/codex-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { parse } from 'smol-toml'

export interface CodexBedrockConfig {
modelProvider?: string
model?: string
profile?: string
region?: string
baseUrl?: string
}

export interface ReadCodexConfigOptions {
codexHome?: string
env?: NodeJS.ProcessEnv
homeDirectory?: string
}

export function getCodexConfigPath(options: ReadCodexConfigOptions = {}): string {
const codexHome = options.codexHome ?? (options.env ?? process.env).CODEX_HOME
return path.join(codexHome ?? path.join(options.homeDirectory ?? os.homedir(), '.codex'), 'config.toml')
}

export async function readCodexBedrockConfig(
options: ReadCodexConfigOptions = {},
): Promise<CodexBedrockConfig | undefined> {
let source: string
try {
source = await fs.readFile(getCodexConfigPath(options), 'utf8')
} catch (error) {
if (isNotFoundError(error)) return undefined
throw error
}

let document: unknown
try {
document = parse(source)
} catch {
throw new Error('Codex config.toml is malformed.')
}
if (!isRecord(document)) return {}
const provider = isRecord(document.model_providers)
? document.model_providers['amazon-bedrock']
: undefined
const aws = isRecord(provider) ? provider.aws : undefined

return {
...optionalString('modelProvider', document.model_provider),
...optionalString('model', document.model),
...(isRecord(aws) ? optionalString('profile', aws.profile) : {}),
...(isRecord(aws) ? optionalString('region', aws.region) : {}),
...(isRecord(provider) ? optionalString('baseUrl', provider.base_url) : {}),
}
}

function optionalString(key: string, value: unknown): Record<string, string> {
return typeof value === 'string' && value.length > 0 ? { [key]: value } : {}
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function isNotFoundError(error: unknown): boolean {
return isRecord(error) && error.code === 'ENOENT'
}
139 changes: 139 additions & 0 deletions agents/codelayer/src/codex/connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import {
InvalidAuthEntryError,
type AuthStore,
type AwsProfileAuthInfo,
} from '@humanlayer/agentlayer-provider-auth'
import { NODE_REGION_CONFIG_FILE_OPTIONS, NODE_REGION_CONFIG_OPTIONS } from '@smithy/config-resolver'
import { loadConfig } from '@smithy/node-config-provider'
import { readCodexBedrockConfig } from './codex-config'

export type CodexConnection =
| { readonly type: 'chatgpt' }
| {
readonly type: 'bedrock'
readonly profile?: string
readonly region?: string
readonly model?: string
readonly baseURL?: string
}

export type ResolvedCodexConnection =
| { type: 'chatgpt' }
| { type: 'custom-responses' }
| { type: 'bedrock'; profile?: string; region: string; model: string; baseURL: string; endpointURL: string }

export interface ResolveCodexConnectionOptions {
explicitConnection?: CodexConnection
authStore: AuthStore
selectedModelId: string
codexHome?: string
env?: NodeJS.ProcessEnv
homeDirectory?: string
hasLegacyOverride?: boolean
regionProvider?: (profile?: string) => Promise<string>
}

export async function resolveCodexConnection(
options: ResolveCodexConnectionOptions,
): Promise<ResolvedCodexConnection> {
const env = options.env ?? process.env
if (options.explicitConnection) {
return options.explicitConnection.type === 'chatgpt'
? { type: 'chatgpt' }
: resolveBedrock(options.explicitConnection, options.selectedModelId, options.regionProvider)
}

const stored = await options.authStore.get('codex_bedrock')
if (stored && stored.kind !== 'aws-profile') throw new InvalidAuthEntryError('codex_bedrock')
if (stored?.kind === 'aws-profile') {
if (stored.active === true) {
return resolveBedrock(fromStored(stored), options.selectedModelId, options.regionProvider)
}
if (stored.active === false) return { type: 'chatgpt' }
}
if (options.hasLegacyOverride) return { type: 'custom-responses' }

const config = await readCodexBedrockConfig({
codexHome: options.codexHome,
env,
homeDirectory: options.homeDirectory,
})
if (config?.modelProvider === 'amazon-bedrock-runtime') {
throw new Error('Codex model provider "amazon-bedrock-runtime" is not supported; use "amazon-bedrock".')
}
if (config?.modelProvider !== 'amazon-bedrock') return { type: 'chatgpt' }
return resolveBedrock({
type: 'bedrock',
profile: stored?.profile ?? config.profile,
region: stored?.region ?? config.region,
model: stored?.model ?? config.model,
baseURL: stored?.baseUrl ?? config.baseUrl,
}, options.selectedModelId, options.regionProvider)
}

function fromStored(auth: AwsProfileAuthInfo): Extract<CodexConnection, { type: 'bedrock' }> {
return {
type: 'bedrock',
profile: auth.profile,
region: auth.region,
model: auth.model,
baseURL: auth.baseUrl,
}
}

async function resolveBedrock(
connection: Extract<CodexConnection, { type: 'bedrock' }>,
selectedModelId: string,
regionProvider: (profile?: string) => Promise<string> = resolveAwsRegion,
): Promise<ResolvedCodexConnection> {
let region = connection.region
if (!region) {
try {
region = await regionProvider(connection.profile)
} catch {
// Normalize the SDK's missing-region errors without exposing config contents.
}
}
if (!region) {
throw new Error('Amazon Bedrock configuration is incomplete: an AWS region could not be resolved.')
}
const baseURL = connection.baseURL ?? `https://bedrock-mantle.${region}.api.aws/openai/v1`
const endpoint = parseResponsesURL(baseURL, 'Amazon Bedrock base URL')
const model = connection.model ?? (selectedModelId.startsWith('openai.') ? selectedModelId : `openai.${selectedModelId}`)
return { type: 'bedrock', profile: connection.profile, region, model, ...endpoint }
}

export function parseResponsesURL(rawValue: string, settingName: string): { baseURL: string; endpointURL: string } {
let url: URL
try {
url = new URL(rawValue)
} catch {
throw new Error(`${settingName} must be an absolute HTTP or HTTPS URL.`)
}
if (url.username || url.password) throw new Error(`${settingName} must not contain a username or password.`)
if (url.search || url.hash) throw new Error(`${settingName} must not contain a query string or fragment.`)
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopbackHostname(url.hostname))) {
throw new Error(`${settingName} must use HTTPS unless it points to a loopback host.`)
}
const normalizedPath = url.pathname.replace(/\/+$/, '')
const isFullEndpoint = normalizedPath.endsWith('/responses')
const basePath = isFullEndpoint ? normalizedPath.slice(0, -'/responses'.length) : normalizedPath
url.pathname = basePath || '/'
const baseURL = url.toString().replace(/\/$/, '')
url.pathname = `${basePath}/responses` || '/responses'
return { baseURL, endpointURL: url.toString() }
}

async function resolveAwsRegion(profile?: string): Promise<string> {
return loadConfig(NODE_REGION_CONFIG_OPTIONS, {
...NODE_REGION_CONFIG_FILE_OPTIONS,
profile,
})()
}

function isLoopbackHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase()
if (normalized === 'localhost' || normalized.endsWith('.localhost') || normalized === '[::1]') return true
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(normalized)
return match !== null && Number(match[1]) === 127 && match.slice(1).every((part) => Number(part) <= 255)
}
Loading
Loading