Skip to content
Merged
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
2 changes: 1 addition & 1 deletion agents/codelayer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ CODELAYER_CODEX_MODEL=my-azure-deployment

The selected CodeLayer model still controls prompts, reasoning, context, and cost data. Only the `model` value sent on the wire changes. Custom requests keep reasoning effort and summary, stateless `store: false`, encrypted reasoning content, and prompt caching. They omit fast mode and `service_tier`.

Setting any optional override without both `CODELAYER_CODEX_BASE_URL` and `CODELAYER_CODEX_API_KEY` fails before CodeLayer reads Codex file auth or sends a request. Restart the Riptide daemon after changing any override value. When all override values are absent, CodeLayer keeps its current Codex file auth and `CODEX_PROVIDER=sse|websockets|aisdk_responses` behavior.
Setting any optional override without both `CODELAYER_CODEX_BASE_URL` and `CODELAYER_CODEX_API_KEY` fails before CodeLayer reads Codex file auth or sends a request. Restart the Riptide daemon after changing any override value. When all override values are absent, CodeLayer keeps its current Codex file auth and `CODEX_PROVIDER=sse|websockets` behavior.

Custom endpoint failures flow through the existing Codex diagnostics sink. The CLI writes them to its Codex diagnostics log; Riptide writes them to daemon logs and captures error events in Sentry. Diagnostic records omit API keys and response bodies.

Expand Down
31 changes: 20 additions & 11 deletions agents/codelayer/src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,11 @@ import { createCopilotProvider } from '@humanlayer/agentlayer-provider-github-co
import {
createCodexSseVendorProvider,
createCodexEffectProvider,
createCodexResponsesProvider,
type CodexDiagnosticsContext,
CODEX_DEFAULT_VERSION,
} from '@humanlayer/agentlayer-provider-openai-codex'

export type CodexProviderMode = 'sse' | 'aisdk_responses' | 'websockets'
export type CodexProviderMode = 'sse' | 'websockets'

export type ProviderType = 'anthropic' | 'openai' | 'codex' | 'copilot' | 'firepass'

Expand Down Expand Up @@ -79,12 +78,17 @@ export async function captureResponseUsage(response: Response, usage: RawCacheUs
if (!response.body || !response.ok) return response
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
// Read once and rebuild rather than clone(): the SDK (>= @ai-sdk/openai
// 3.0.96) reads the body in a way that races the clone's tee under Bun,
// surfacing as "JSON Parse error: Unexpected EOF" from a half-drained
// stream. A rebuilt Response hands it a fresh, fully-buffered body.
const text = await response.text()
Comment on lines +81 to +85

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ai SDK is not used this doesn't make sense

try {
captureCacheUsage(await response.clone().json(), usage)
captureCacheUsage(JSON.parse(text), usage)
} catch {
// The SDK parses and reports malformed JSON from the original response.
// The SDK parses and reports malformed JSON itself.
}
return response
return new Response(text, { status: response.status, statusText: response.statusText, headers: response.headers })
}
if (!contentType.includes('text/event-stream')) return response

Expand Down Expand Up @@ -265,7 +269,7 @@ function reportCustomResponsesError(options: {
options.diagnostics.onEvent({
event: 'codex.provider.custom_responses.failed',
severity: 'error',
transport: 'aisdk_responses',
transport: 'custom_responses',
annotations: options.diagnostics.annotations,
metadata: {
error: safeMessage,
Expand Down Expand Up @@ -438,9 +442,16 @@ export async function resolveModel(
}

const authStore = await ensureFileAuthStore()
const codexMode = context?.codexProviderMode
?? (process.env.CODEX_PROVIDER as CodexProviderMode | undefined)
?? 'sse'
const requestedRaw = context?.codexProviderMode ?? (process.env.CODEX_PROVIDER as string | undefined)
// An empty env value (CODEX_PROVIDER= from a template) means unset, not unknown.
const requestedMode = requestedRaw === '' ? undefined : requestedRaw
// Unknown or retired CODEX_PROVIDER values fall back to the default
// transport instead of crashing a daemon that still carries the env var.
const codexMode: CodexProviderMode =
requestedMode === 'sse' || requestedMode === 'websockets' ? requestedMode : 'sse'
if (requestedMode !== undefined && requestedMode !== codexMode) {
console.error(`[codex-provider] unknown transport '${requestedMode}', falling back to 'sse'`)
}
const codexOpts = {
authStore,
version: CODEX_DEFAULT_VERSION,
Expand All @@ -450,8 +461,6 @@ export async function resolveModel(
}
console.error(`[codex-provider] using ${codexMode} transport for model ${modelId}`)
switch (codexMode) {
case 'aisdk_responses':
return createCodexResponsesProvider(codexOpts).languageModel(modelId) as LanguageModel
case 'websockets':
return createCodexEffectProvider(codexOpts).languageModel(modelId) as LanguageModel
case 'sse':
Expand Down
21 changes: 9 additions & 12 deletions agents/codelayer/test/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,6 @@ describe('provider resolution', () => {
process.env.CODELAYER_CODEX_MODEL = 'azure-coding-deployment'
process.env.CODEX_PROVIDER = 'websockets'
const sseSpy = spyOn(codexProvider, 'createCodexSseVendorProvider')
const responsesSpy = spyOn(codexProvider, 'createCodexResponsesProvider')
const websocketSpy = spyOn(codexProvider, 'createCodexEffectProvider')

const model = await resolveModel('codex', 'gpt-5.6-sol')
Expand All @@ -205,14 +204,12 @@ describe('provider resolution', () => {
expect((model as { modelId: string }).modelId).toBe('gpt-5.6-sol')
expect(providerAuth.ensureFileAuthStore).not.toHaveBeenCalled()
expect(sseSpy).not.toHaveBeenCalled()
expect(responsesSpy).not.toHaveBeenCalled()
expect(websocketSpy).not.toHaveBeenCalled()
})

test('rejects partial custom Codex settings before auth or private provider selection', async () => {
process.env.CODELAYER_CODEX_BASE_URL = 'https://example.test/openai/v1'
const sseSpy = spyOn(codexProvider, 'createCodexSseVendorProvider')
const responsesSpy = spyOn(codexProvider, 'createCodexResponsesProvider')
const websocketSpy = spyOn(codexProvider, 'createCodexEffectProvider')

await expect(resolveModel('codex', 'gpt-5.6-sol')).rejects.toThrow('CODELAYER_CODEX_API_KEY')
Expand All @@ -221,22 +218,18 @@ describe('provider resolution', () => {
await expect(resolveModel('codex', 'gpt-5.6-sol')).rejects.toThrow('CODELAYER_CODEX_BASE_URL')
expect(providerAuth.ensureFileAuthStore).not.toHaveBeenCalled()
expect(sseSpy).not.toHaveBeenCalled()
expect(responsesSpy).not.toHaveBeenCalled()
expect(websocketSpy).not.toHaveBeenCalled()
})

test('keeps every private Codex transport available when the override is absent', async () => {
const sseSpy = spyOn(codexProvider, 'createCodexSseVendorProvider')
const responsesSpy = spyOn(codexProvider, 'createCodexResponsesProvider')
const websocketSpy = spyOn(codexProvider, 'createCodexEffectProvider')

await resolveModel('codex', 'gpt-5.5', { codexProviderMode: 'sse' })
await resolveModel('codex', 'gpt-5.5', { codexProviderMode: 'aisdk_responses' })
await resolveModel('codex', 'gpt-5.5', { codexProviderMode: 'websockets' })

expect(providerAuth.ensureFileAuthStore).toHaveBeenCalledTimes(3)
expect(providerAuth.ensureFileAuthStore).toHaveBeenCalledTimes(2)
expect(sseSpy).toHaveBeenCalledTimes(1)
expect(responsesSpy).toHaveBeenCalledTimes(1)
expect(websocketSpy).toHaveBeenCalledTimes(1)
})

Expand Down Expand Up @@ -267,13 +260,17 @@ describe('provider resolution', () => {
)
})

test('respects explicit codex provider mode from caller context', async () => {
const providerSpy = spyOn(codexProvider, 'createCodexResponsesProvider')
test('falls back to the SSE transport when a retired mode is requested', async () => {
const sseSpy = spyOn(codexProvider, 'createCodexSseVendorProvider')

const model = await resolveModel('codex', 'gpt-5.5', { codexProviderMode: 'aisdk_responses' })
// 'aisdk_responses' was removed; a daemon still carrying the env var or a
// stale context value must degrade to the default transport, not crash.
const model = await resolveModel('codex', 'gpt-5.5', {
codexProviderMode: 'aisdk_responses' as never,
})

expect(model).toBeDefined()
expect(providerSpy).toHaveBeenCalled()
expect(sseSpy).toHaveBeenCalled()
})

test('respects CODEX_PROVIDER when caller context does not set a mode', async () => {
Expand Down
15 changes: 11 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
],
"catalog": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.69",
"@ai-sdk/openai": "3.0.96",
"@ai-sdk/provider": "3.0.10",
"@ai-sdk/provider-utils": "4.0.27",
"@pulumi/aws": "7.26.0",
Expand Down
11 changes: 10 additions & 1 deletion packages/agentlayer-core/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,14 @@ import { sanitizeTextForModelState, sanitizeToolOutputForModelState } from './sa
import type { AgentState, ApprovalDecision, ApprovalHistoryEntry, TerminalChildMap } from './state'
import type { Step, StepToolResult, StopResult, StopTiming, StopWhen } from './stop-conditions'
import { shouldStop } from './stop-conditions'
import { extractUsage, getModelKey, type ModelTokenUsage, type TokenUsage, TokenUsageAccumulator } from './token-usage'
import {
extractUsage,
getModelKey,
type ModelTokenUsage,
sumOrPoison,
type TokenUsage,
TokenUsageAccumulator,
} from './token-usage'

export type ProviderOptions = Parameters<typeof streamText>[0]['providerOptions']
export type ProviderOptionsFactory = (ctx: { runId: string; promptCacheKey?: string }) => ProviderOptions
Expand Down Expand Up @@ -444,6 +451,7 @@ export class Agent<TTools extends Record<string, Tool<any, any>> = Record<string
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
noCacheInputTokens: 0,
}
const inferenceMessages: ModelMessage[] = []
const summarize = async (requestText: string, turnPrefix: boolean): Promise<string> => {
Expand Down Expand Up @@ -478,6 +486,7 @@ export class Agent<TTools extends Record<string, Tool<any, any>> = Record<string
] as const) {
summaryUsage[key] += callUsage[key]
}
summaryUsage.noCacheInputTokens = sumOrPoison(summaryUsage.noCacheInputTokens, callUsage.noCacheInputTokens)
const responseMessages = response.messages.filter((message) => message.role !== 'tool')
const summary = responseMessages
.filter((message) => message.role === 'assistant')
Expand Down
Loading
Loading