diff --git a/agents/codelayer/README.md b/agents/codelayer/README.md index 93a878f..682490a 100644 --- a/agents/codelayer/README.md +++ b/agents/codelayer/README.md @@ -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. diff --git a/agents/codelayer/src/providers.ts b/agents/codelayer/src/providers.ts index 6439fc7..04f1a9f 100644 --- a/agents/codelayer/src/providers.ts +++ b/agents/codelayer/src/providers.ts @@ -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' @@ -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() 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 @@ -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, @@ -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, @@ -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': diff --git a/agents/codelayer/test/agent.test.ts b/agents/codelayer/test/agent.test.ts index 007029e..c56d94f 100644 --- a/agents/codelayer/test/agent.test.ts +++ b/agents/codelayer/test/agent.test.ts @@ -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') @@ -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') @@ -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) }) @@ -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 () => { diff --git a/bun.lock b/bun.lock index 9acfe86..8fecfa0 100644 --- a/bun.lock +++ b/bun.lock @@ -219,7 +219,6 @@ "name": "@humanlayer/agentlayer-provider-openai-codex", "version": "0.0.36", "dependencies": { - "@ai-sdk/openai": "catalog:", "@ai-sdk/provider": "catalog:", "@humanlayer/agentlayer-core": "workspace:*", "@humanlayer/agentlayer-provider-auth": "workspace:*", @@ -370,7 +369,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", @@ -388,7 +387,7 @@ "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.127", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Obmw5hmE5x+ccRrMp/Djx5r0rpFVX87YqE6OY06g5fwYlRI30dA84ARfTzX45ivCvkW4eCnBpOVXVWQ/pjH85w=="], - "@ai-sdk/openai": ["@ai-sdk/openai@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-T/J3ED6ERDsine+crkXHfraisSG20k6BkBdgjvXCvySYnnJ19IaLIOsQPYfvXdOsKrl0LVNghooTV/nKCA7SmQ=="], + "@ai-sdk/openai": ["@ai-sdk/openai@3.0.96", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.45" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Pex8vOj1y05j7jtBS39cJJRDjJbMIyCY9+01cSIp1hwEJTKImrFejMgsAazMWXSi/HU+B9ZE6ElftCOwvg4mmQ=="], "@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], @@ -584,6 +583,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], "@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="], @@ -2276,7 +2277,7 @@ "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], - "undici": ["undici@6.25.0", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="], + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], @@ -2394,6 +2395,10 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="], + + "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.45", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^5.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7u5B/E2uZmU65SlJhhQGFHZwRCN0xOz4HHtFc4sEGV9PHbX3fGiEiZBpc/SABay1dGeJgK3VD60rvLGoWdWPXA=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], @@ -2518,6 +2523,8 @@ "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "node-gyp/undici": ["undici@6.25.0", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="], + "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], "normalize-package-data/hosted-git-info": ["hosted-git-info@7.0.2", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w=="], diff --git a/package.json b/package.json index 8ca0a5d..d843efe 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/agentlayer-core/src/agent.ts b/packages/agentlayer-core/src/agent.ts index d436681..ff27f97 100644 --- a/packages/agentlayer-core/src/agent.ts +++ b/packages/agentlayer-core/src/agent.ts @@ -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[0]['providerOptions'] export type ProviderOptionsFactory = (ctx: { runId: string; promptCacheKey?: string }) => ProviderOptions @@ -444,6 +451,7 @@ export class Agent> = Record => { @@ -478,6 +486,7 @@ export class Agent> = Record message.role !== 'tool') const summary = responseMessages .filter((message) => message.role === 'assistant') diff --git a/packages/agentlayer-core/src/token-usage.ts b/packages/agentlayer-core/src/token-usage.ts index 45f0dd8..7a4529c 100644 --- a/packages/agentlayer-core/src/token-usage.ts +++ b/packages/agentlayer-core/src/token-usage.ts @@ -9,6 +9,17 @@ export interface ModelTokenUsage { cacheReadTokens: number cacheWriteTokens: number reasoningTokens: number + /** + * Provider-reported uncached prompt tokens (AI SDK + * `inputTokenDetails.noCacheTokens`), summed across calls. `undefined` when + * any accumulated call omitted it — a partial sum would silently + * misrepresent the models that did report it. When present, costing uses + * this number instead of deriving `inputTokens - cacheRead - cacheWrite`, + * so a provider whose breakdown doesn't perfectly telescope (rounding, + * cache-block granularity) is billed on its own accounting rather than + * ours. + */ + noCacheInputTokens?: number estimatedCostUsd: number | undefined } @@ -42,9 +53,30 @@ export function extractUsage(usage: LanguageModelUsage): Omit= 0 + ? usage.inputTokenDetails.noCacheTokens + : undefined, } } +/** + * Sums two optional counters under the poisoning rule: one missing operand + * makes the sum `undefined`, because a partial sum presented as a complete one + * is worse than none. This is a business rule, not a convenience — every place + * that accumulates `noCacheInputTokens` must use it so the policy cannot + * diverge between call sites. + */ +export function sumOrPoison(a: number | undefined, b: number | undefined): number | undefined { + return a !== undefined && b !== undefined ? a + b : undefined +} + function emptyTotals(): TokenTotals { return { inputTokens: 0, @@ -83,6 +115,10 @@ export class TokenUsageAccumulator { existing.cacheReadTokens += usage.cacheReadTokens existing.cacheWriteTokens += usage.cacheWriteTokens existing.reasoningTokens += usage.reasoningTokens + // One silent call poisons the model's sum so costing falls back to + // derivation instead of billing a partial "uncached" figure as if it + // covered all calls. + existing.noCacheInputTokens = sumOrPoison(existing.noCacheInputTokens, usage.noCacheInputTokens) } else { this.byModel[modelKey] = { ...usage } } @@ -91,31 +127,79 @@ export class TokenUsageAccumulator { snapshot(): TokenUsage { const totals = emptyTotals() const byModel: Record = {} + // Same poisoning rule as add(): the total is only meaningful when every + // model reported its own uncached figure. + let totalNoCache: number | undefined = 0 for (const [modelKey, usage] of Object.entries(this.byModel)) { const pricing = this.pricingLookup?.(modelKey as ModelKey) - const cacheReadTokens = Math.min(Math.max(0, usage.cacheReadTokens), usage.inputTokens) - const cacheWriteTokens = Math.min(Math.max(0, usage.cacheWriteTokens), usage.inputTokens - cacheReadTokens) - const uncachedInputTokens = usage.inputTokens - cacheReadTokens - cacheWriteTokens + // Prefer the provider's own uncached figure over deriving it — the + // subtraction is a fallback for providers that only report the + // inclusive total. The provider figure is TRUSTED only when the rest + // of the prompt is accounted for: either cache counters exist to + // price the remainder, or the figure covers the whole prompt. A bare + // noCache below the total with no cache counters would price the + // cached remainder at $0, so it falls back to derivation instead. + // Whichever side wins, the priced categories are reconciled to + // PARTITION the prompt total: without the cap a non-telescoping + // breakdown (rounding, cache-block granularity) would bill more + // prompt tokens than the prompt contained. + const promptTotal = Math.max(0, usage.inputTokens) + // Trust requires COVERAGE, not mere presence: the reported categories + // together must account for the whole prompt, or the uncovered + // remainder would be priced at $0. + const reportedCoverage = + (usage.noCacheInputTokens ?? 0) + + Math.max(0, usage.cacheReadTokens) + + Math.max(0, usage.cacheWriteTokens) + const uncachedInputTokens = + usage.noCacheInputTokens !== undefined && reportedCoverage >= promptTotal + ? Math.min(usage.noCacheInputTokens, promptTotal) + : undefined + const cacheReadTokens = Math.min( + Math.max(0, usage.cacheReadTokens), + promptTotal - (uncachedInputTokens ?? 0), + ) + const cacheWriteTokens = Math.min( + Math.max(0, usage.cacheWriteTokens), + promptTotal - (uncachedInputTokens ?? 0) - cacheReadTokens, + ) + const pricedUncachedTokens = uncachedInputTokens ?? promptTotal - cacheReadTokens - cacheWriteTokens const estimatedCostUsd = pricing - ? (uncachedInputTokens * pricing.input) / 1_000_000 + + ? (pricedUncachedTokens * pricing.input) / 1_000_000 + (usage.outputTokens * pricing.output) / 1_000_000 + (cacheReadTokens * (pricing.cacheRead ?? pricing.input)) / 1_000_000 + (cacheWriteTokens * (pricing.cacheWrite ?? pricing.input)) / 1_000_000 : undefined - byModel[modelKey] = { ...usage, estimatedCostUsd } + // Publish the RECONCILED figures (the ones costing used), never the + // raw reports: raw values in byModel/totals could exceed inputTokens, + // break the partition property, and contradict the billed cost for + // any consumer re-deriving it from the published counters. + byModel[modelKey] = { + ...usage, + cacheReadTokens, + cacheWriteTokens, + noCacheInputTokens: uncachedInputTokens, + estimatedCostUsd, + } totals.inputTokens += usage.inputTokens totals.outputTokens += usage.outputTokens - totals.cacheReadTokens += usage.cacheReadTokens - totals.cacheWriteTokens += usage.cacheWriteTokens + totals.cacheReadTokens += cacheReadTokens + totals.cacheWriteTokens += cacheWriteTokens totals.reasoningTokens += usage.reasoningTokens + totalNoCache = sumOrPoison(totalNoCache, uncachedInputTokens) if (estimatedCostUsd !== undefined) { totals.estimatedCostUsd = (totals.estimatedCostUsd ?? 0) + estimatedCostUsd } } + // An empty accumulator has no calls to vouch for; leave it undefined. + if (Object.keys(this.byModel).length > 0 && totalNoCache !== undefined) { + totals.noCacheInputTokens = totalNoCache + } + return { byModel, totals } } } diff --git a/packages/agentlayer-core/test/token-usage.test.ts b/packages/agentlayer-core/test/token-usage.test.ts index 4d9bde3..b402894 100644 --- a/packages/agentlayer-core/test/token-usage.test.ts +++ b/packages/agentlayer-core/test/token-usage.test.ts @@ -123,6 +123,139 @@ describe('TokenUsageAccumulator', () => { expect(acc.snapshot().byModel['provider/model']!.estimatedCostUsd).toBeCloseTo(0.099) }) + test('prefers the provider-reported uncached figure and reconciles the cache counters around it', () => { + const acc = new TokenUsageAccumulator(() => ({ input: 10, output: 0, cacheRead: 1, cacheWrite: 12.5 })) + // A breakdown that does NOT perfectly telescope (total − read = 200k, but + // the provider says 250k uncached — e.g. cache-block rounding). The + // provider's uncached figure wins, and cacheRead is capped to the + // remainder so the priced categories PARTITION the 1M prompt — billing + // 1.05M category-tokens for a 1M prompt would overcharge. + acc.add('provider/model', { + inputTokens: 1_000_000, + outputTokens: 0, + cacheReadTokens: 800_000, + cacheWriteTokens: 0, + reasoningTokens: 0, + noCacheInputTokens: 250_000, + }) + // reported: 250k × $10/M + min(800k, 750k) × $1/M = 3.25. Derived would be 2.80. + const snapshot = acc.snapshot() + expect(snapshot.byModel['provider/model']!.estimatedCostUsd).toBeCloseTo(3.25) + // The PUBLISHED counters must partition the prompt and re-derive the + // billed cost — raw values (800k read on a 1M prompt with 250k uncached) + // would sum to 1.05M category-tokens and contradict it. + expect(snapshot.byModel['provider/model']!.cacheReadTokens).toBe(750_000) + expect(snapshot.byModel['provider/model']!.noCacheInputTokens).toBe(250_000) + expect(snapshot.totals.cacheReadTokens).toBe(750_000) + }) + + test('clamps a pathological provider uncached figure to the prompt total', () => { + const acc = new TokenUsageAccumulator(() => ({ input: 10, output: 0, cacheRead: 1 })) + // A cumulative/garbage noCache far above the prompt: cap at inputTokens + // so it cannot bill more prompt than existed. + acc.add('provider/model', { + inputTokens: 100_000, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + noCacheInputTokens: 5_000_000, + }) + expect(acc.snapshot().byModel['provider/model']!.estimatedCostUsd).toBeCloseTo(1.0) + }) + + test('one call without noCacheInputTokens poisons the model sum to undefined and costing falls back', () => { + const acc = new TokenUsageAccumulator(() => ({ input: 10, output: 0, cacheRead: 1 })) + acc.add('provider/model', { + inputTokens: 1_000_000, + outputTokens: 0, + cacheReadTokens: 800_000, + cacheWriteTokens: 0, + reasoningTokens: 0, + noCacheInputTokens: 200_000, + }) + acc.add('provider/model', { + inputTokens: 1_000_000, + outputTokens: 0, + cacheReadTokens: 800_000, + cacheWriteTokens: 0, + reasoningTokens: 0, + // no noCacheInputTokens — a partial sum would misrepresent the run + }) + const snapshot = acc.snapshot() + expect(snapshot.byModel['provider/model']!.noCacheInputTokens).toBeUndefined() + expect(snapshot.totals.noCacheInputTokens).toBeUndefined() + // Derived: (2M − 1.6M) × $10/M + 1.6M × $1/M = 5.60 + expect(snapshot.byModel['provider/model']!.estimatedCostUsd).toBeCloseTo(5.6) + }) + + test('sums noCacheInputTokens across calls and models when every call reports it', () => { + const acc = new TokenUsageAccumulator() + acc.add('provider/a', { + inputTokens: 1000, + outputTokens: 0, + cacheReadTokens: 700, + cacheWriteTokens: 0, + reasoningTokens: 0, + noCacheInputTokens: 300, + }) + acc.add('provider/a', { + inputTokens: 500, + outputTokens: 0, + cacheReadTokens: 400, + cacheWriteTokens: 0, + reasoningTokens: 0, + noCacheInputTokens: 100, + }) + acc.add('provider/b', { + inputTokens: 200, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + noCacheInputTokens: 200, + }) + const snapshot = acc.snapshot() + expect(snapshot.byModel['provider/a']!.noCacheInputTokens).toBe(400) + expect(snapshot.byModel['provider/b']!.noCacheInputTokens).toBe(200) + expect(snapshot.totals.noCacheInputTokens).toBe(600) + }) + + test('falls back to derivation when noCache is reported without any cache counters', () => { + const acc = new TokenUsageAccumulator(() => ({ input: 10, output: 0, cacheRead: 1 })) + // noCache 250k on a 1M prompt with NO cache counters: trusting it would + // price the 750k cached remainder at $0. Derivation bills the full input + // (matching pre-noCache behavior); the published figure is poisoned. + acc.add('provider/model', { + inputTokens: 1_000_000, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + noCacheInputTokens: 250_000, + }) + const snapshot = acc.snapshot() + expect(snapshot.byModel['provider/model']!.estimatedCostUsd).toBeCloseTo(10.0) + expect(snapshot.byModel['provider/model']!.noCacheInputTokens).toBeUndefined() + }) + + test('publishes the reconciled noCache figure, never the raw pathological report', () => { + const acc = new TokenUsageAccumulator(() => ({ input: 10, output: 0, cacheRead: 1 })) + acc.add('provider/model', { + inputTokens: 100_000, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + noCacheInputTokens: 5_000_000, + }) + const snapshot = acc.snapshot() + // Billed from the clamped figure, and the EXPORTED figure matches it — + // a consumer deriving cached = input - noCache must never go negative. + expect(snapshot.byModel['provider/model']!.noCacheInputTokens).toBe(100_000) + expect(snapshot.totals.noCacheInputTokens).toBe(100_000) + }) + test('unknown model has undefined cost', () => { const acc = new TokenUsageAccumulator(() => undefined) acc.add('unknown/model', { @@ -152,6 +285,30 @@ describe('extractUsage', () => { expect(usage.cacheReadTokens).toBe(150) expect(usage.cacheWriteTokens).toBe(50) expect(usage.reasoningTokens).toBe(50) + expect(usage.noCacheInputTokens).toBe(800) + }) + + test('treats a negative noCacheTokens report as absent, not as zero', () => { + const usage = extractUsage({ + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + inputTokenDetails: { noCacheTokens: -1, cacheReadTokens: undefined, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, + }) + // Clamping garbage to 0 would bill the whole prompt at $0 downstream. + expect(usage.noCacheInputTokens).toBeUndefined() + }) + + test('keeps noCacheInputTokens undefined when the provider omits it — absence is meaningful', () => { + const usage = extractUsage({ + inputTokens: 1000, + outputTokens: 500, + totalTokens: 1500, + inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: 150, cacheWriteTokens: 50 }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, + }) + expect(usage.noCacheInputTokens).toBeUndefined() }) test('handles undefined detail fields gracefully', () => { diff --git a/packages/agentlayer-provider-github-copilot/src/sdk/copilot/responses/openai-responses-language-model.ts b/packages/agentlayer-provider-github-copilot/src/sdk/copilot/responses/openai-responses-language-model.ts index 2ed0ffc..0f31d02 100644 --- a/packages/agentlayer-provider-github-copilot/src/sdk/copilot/responses/openai-responses-language-model.ts +++ b/packages/agentlayer-provider-github-copilot/src/sdk/copilot/responses/openai-responses-language-model.ts @@ -750,12 +750,13 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { usage: { inputTokens: { total: response.usage.input_tokens, - noCache: - response.usage.input_tokens_details?.cached_tokens != null - ? response.usage.input_tokens - response.usage.input_tokens_details.cached_tokens - : undefined, + noCache: deriveNoCacheTokens( + response.usage.input_tokens, + response.usage.input_tokens_details?.cached_tokens, + response.usage.input_tokens_details?.cache_write_tokens, + ), cacheRead: response.usage.input_tokens_details?.cached_tokens ?? undefined, - cacheWrite: undefined, + cacheWrite: response.usage.input_tokens_details?.cache_write_tokens ?? undefined, }, outputTokens: { total: response.usage.output_tokens, @@ -812,12 +813,14 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { totalTokens: number | undefined reasoningTokens: number | undefined cachedInputTokens: number | undefined + cacheWriteInputTokens: number | undefined } = { inputTokens: undefined, outputTokens: undefined, totalTokens: undefined, reasoningTokens: undefined, cachedInputTokens: undefined, + cacheWriteInputTokens: undefined, } const logprobs: Array> = [] let responseId: string | null = null @@ -1282,6 +1285,8 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { value.response.usage.output_tokens_details?.reasoning_tokens ?? undefined usage.cachedInputTokens = value.response.usage.input_tokens_details?.cached_tokens ?? undefined + usage.cacheWriteInputTokens = + value.response.usage.input_tokens_details?.cache_write_tokens ?? undefined if (typeof value.response.service_tier === 'string') { serviceTier = value.response.service_tier } @@ -1338,12 +1343,13 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { usage: { inputTokens: { total: usage.inputTokens, - noCache: - usage.inputTokens != null && usage.cachedInputTokens != null - ? usage.inputTokens - usage.cachedInputTokens - : undefined, + noCache: deriveNoCacheTokens( + usage.inputTokens, + usage.cachedInputTokens, + usage.cacheWriteInputTokens, + ), cacheRead: usage.cachedInputTokens, - cacheWrite: undefined, + cacheWrite: usage.cacheWriteInputTokens, }, outputTokens: { total: usage.outputTokens, @@ -1368,9 +1374,26 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { } } +// One derivation for both doGenerate and doStream: noCache is only a fact when +// the backend reported at least one cache counter — details: {} (or null +// fields) means "unknown", and fabricating noCache = input_tokens there would +// present a guess as a provider-vouched figure and bypass downstream +// absence-keyed fallbacks. Both counters are SUBSETS of the input total. +function deriveNoCacheTokens( + inputTokens: number | undefined, + cacheRead: number | null | undefined, + cacheWrite: number | null | undefined, +): number | undefined { + if (inputTokens == null) return undefined + if (cacheRead == null && cacheWrite == null) return undefined + return Math.max(inputTokens - (cacheRead ?? 0) - (cacheWrite ?? 0), 0) +} + const usageSchema = z.object({ input_tokens: z.number(), - input_tokens_details: z.object({ cached_tokens: z.number().nullish() }).nullish(), + input_tokens_details: z + .object({ cached_tokens: z.number().nullish(), cache_write_tokens: z.number().nullish() }) + .nullish(), output_tokens: z.number(), output_tokens_details: z.object({ reasoning_tokens: z.number().nullish() }).nullish(), }) diff --git a/packages/agentlayer-provider-openai-codex/README.md b/packages/agentlayer-provider-openai-codex/README.md index 5d9f982..de6b630 100644 --- a/packages/agentlayer-provider-openai-codex/README.md +++ b/packages/agentlayer-provider-openai-codex/README.md @@ -13,9 +13,9 @@ bun add @humanlayer/agentlayer-provider-openai-codex @humanlayer/agentlayer-prov ```ts import { Agent, startState, userMessage } from '@humanlayer/agentlayer-core' import { createMemoryAuthStore } from '@humanlayer/agentlayer-provider-auth' -import { createCodexProvider } from '@humanlayer/agentlayer-provider-openai-codex' +import { createCodexSseVendorProvider } from '@humanlayer/agentlayer-provider-openai-codex' -const codex = createCodexProvider({ +const codex = createCodexSseVendorProvider({ authStore: createMemoryAuthStore({ codex: { kind: 'oauth', accessToken: process.env.CODEX_ACCESS_TOKEN! }, }), @@ -27,22 +27,16 @@ const { state } = await agent.run({ state: startState([userMessage('Hello')]), s ## Providers -The package exports four provider factories with different transport/parsing tradeoffs; swap the import to change providers, everything else stays the same. +The package exports two provider factories with different transport tradeoffs; swap the import to change providers, everything else stays the same. (The hand-rolled `createCodexProvider` and the `@ai-sdk/openai`-delegating `createCodexResponsesProvider` were removed: neither had runtime consumers, and the latter inherited an upstream usage schema that dropped GPT-5.6's `cache_write_tokens` at the then-pinned version.) -### 1. `createCodexProvider` — hand-rolled SSE (legacy) -Full SSE byte-stream parsing, event dispatch, and a 120s per-chunk watchdog (`readWithTimeout`). No dependency on `@ai-sdk/openai` for streaming. Also exports its building blocks from `./legacy` (`buildCodexRequestBody`, `buildCodexHeaders`, `transformCodexPrompt`, `createCodexSseStream`, `parseCodexSseResponse`, ...) for advanced use. - -### 2. `createCodexResponsesProvider` — thin wrapper over `@ai-sdk/openai` -Delegates SSE parsing to `@ai-sdk/openai`'s `responses()` model. This package only patches `fetch` to handle auth, headers, URL rewriting, and Codex-specific body cleanup (forces `store: false`, moves `system` messages into `instructions`, strips `id`/`previous_response_id`/`max_output_tokens`). Adds a configurable per-chunk watchdog and a header-arrival watchdog. - -### 3. `createCodexSseVendorProvider` — Effect-based parser over HTTP SSE +### 1. `createCodexSseVendorProvider` — Effect-based parser over HTTP SSE Builds requests through the shared `LLMRequest` adapter (`./shared/adapter`) and streams via the vendored `@humanlayer/opencode-llm-vendor` `LLMClient` over HTTP SSE (`httpSseRoute`). Reports structured records to `diagnostics.onEvent` when configured. -### 4. `createCodexEffectProvider` — Effect-based parser over WebSocket -Same adapter/`LLMClient` pipeline as #3, but transports over a WebSocket connection (`webSocketRoute` + `WebSocketExecutor`) instead of HTTP SSE. Lives in `./providers/websockets-vendor-provider`. +### 2. `createCodexEffectProvider` — Effect-based parser over WebSocket +Same adapter/`LLMClient` pipeline as #1, but transports over a WebSocket connection (`webSocketRoute` + `WebSocketExecutor`) instead of HTTP SSE. Lives in `./providers/websockets-vendor-provider`. ```ts -import { createCodexEffectProvider, createCodexResponsesProvider, createCodexSseVendorProvider } from '@humanlayer/agentlayer-provider-openai-codex' +import { createCodexEffectProvider, createCodexSseVendorProvider } from '@humanlayer/agentlayer-provider-openai-codex' const codex = createCodexEffectProvider({ authStore, fastMode: true }) const model = codex.languageModel('codex-mini-latest') @@ -64,7 +58,7 @@ interface CodexProviderOptions { } ``` -`createCodexResponsesProvider` additionally accepts `chunkTimeout`/`headerTimeout` (ms; default `120000`/`10000`, pass `false` to disable). The vendor-backed providers (`createCodexSseVendorProvider`, `createCodexEffectProvider`) use fixed internal stream timeouts and don't expose these as options. +Both providers use fixed internal stream timeouts and don't expose timeout options. ## Fast mode & service tier @@ -77,21 +71,17 @@ await model.doStream({ }) ``` -`serviceTier` takes precedence over `fastMode`, and `"fast"` is always normalized to `"priority"` (`normalizeCodexServiceTier`). `createCodexProvider` (legacy) reads options from both `providerOptions.openai` and `providerOptions.codex`; the vendor-backed providers (`createCodexSseVendorProvider`, `createCodexEffectProvider`) only read `providerOptions.openai`. +`serviceTier` takes precedence over `fastMode`, and `"fast"` is always normalized to `"priority"` (`normalizeCodexServiceTier`). Both providers read options from `providerOptions.openai`. ## Architecture ```mermaid flowchart LR Agent["Agent (agentlayer-core)"] --> Model["languageModel() : LanguageModelV3"] - Model --> P1["createCodexProvider\n(hand-rolled SSE)"] - Model --> P2["createCodexResponsesProvider\n(@ai-sdk/openai wrapper)"] - Model --> P3["createCodexSseVendorProvider\n(Effect + HTTP SSE)"] - Model --> P4["createCodexEffectProvider\n(Effect + WebSocket)"] + Model --> P1["createCodexSseVendorProvider\n(Effect + HTTP SSE)"] + Model --> P2["createCodexEffectProvider\n(Effect + WebSocket)"] P1 --> API["chatgpt.com/backend-api/codex/responses"] P2 --> API - P3 --> API - P4 --> API ``` ## Other exports diff --git a/packages/agentlayer-provider-openai-codex/package.json b/packages/agentlayer-provider-openai-codex/package.json index e29dbc1..655284e 100644 --- a/packages/agentlayer-provider-openai-codex/package.json +++ b/packages/agentlayer-provider-openai-codex/package.json @@ -37,7 +37,6 @@ } }, "dependencies": { - "@ai-sdk/openai": "catalog:", "@ai-sdk/provider": "catalog:", "@humanlayer/agentlayer-core": "workspace:*", "@humanlayer/agentlayer-provider-auth": "workspace:*", diff --git a/packages/agentlayer-provider-openai-codex/src/index.ts b/packages/agentlayer-provider-openai-codex/src/index.ts index d4c2689..c7ed408 100644 --- a/packages/agentlayer-provider-openai-codex/src/index.ts +++ b/packages/agentlayer-provider-openai-codex/src/index.ts @@ -1,22 +1,9 @@ // --- Providers --- -export type CodexProviderMode = 'sse' | 'aisdk_responses' | 'websockets' +export type CodexProviderMode = 'sse' | 'websockets' // --- JWT --- export * from './jwt' -export type { CodexRequestBody } from './legacy' -// --- Legacy (from old codex.ts — keep for backward compat until removed) --- -export { - buildCodexHeaders, - buildCodexRequestBody, - createCodexLanguageModel, - createCodexProvider, - createCodexSseStream, - parseCodexSseResponse, - prepareCodexRequest, - streamPartsToGenerateResult, - transformCodexPrompt, -} from './legacy' // --- OAuth --- export { type BrowserOAuthStartResult, @@ -44,7 +31,6 @@ export { startDeviceOAuth, writeOAuthTokens, } from './oauth' -export { type CodexResponsesProviderOptions, createCodexResponsesProvider } from './providers/aisdk-codex-provider' export { type CodexSseVendorProviderOptions, createCodexSseVendorProvider } from './providers/sse-vendor-provider' export { type CodexEffectProviderOptions, createCodexEffectProvider } from './providers/websockets-vendor-provider' // --- Auth --- diff --git a/packages/agentlayer-provider-openai-codex/src/legacy.ts b/packages/agentlayer-provider-openai-codex/src/legacy.ts deleted file mode 100644 index d56a92e..0000000 --- a/packages/agentlayer-provider-openai-codex/src/legacy.ts +++ /dev/null @@ -1,1420 +0,0 @@ -import os from 'node:os' -import { - type LanguageModelV3, - type LanguageModelV3CallOptions, - type LanguageModelV3Content, - type LanguageModelV3FinishReason, - type LanguageModelV3GenerateResult, - type LanguageModelV3Prompt, - type LanguageModelV3StreamPart, - type LanguageModelV3Usage, - NoSuchModelError, - type ProviderV3, - type SharedV3ProviderMetadata, -} from '@ai-sdk/provider' -import type { AuthInfo, AuthStore, OAuthAuthInfo } from '@humanlayer/agentlayer-provider-auth' -import { createFileAuthStore } from '@humanlayer/agentlayer-provider-auth' -import { type CodexFetchLike, refreshAccessToken } from './oauth' - -export const CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses' -export const CODEX_PROVIDER = 'openai.codex' -export const CODEX_PROVIDER_ID = 'codex' -export const CODEX_FAST_SERVICE_TIER = 'priority' -export const CODEX_FLEX_SERVICE_TIER = 'flex' -export const CODEX_DEFAULT_VERSION = '1.15.7' - -export interface CodexRequestOptions { - /** - * Enable Codex fast mode. This sends `service_tier: "priority"`, matching - * the Codex CLI's fast-mode request behavior. - */ - fastMode?: boolean - /** - * Explicit Codex service tier. The convenience value `"fast"` is normalized - * to the API value `"priority"`. - */ - serviceTier?: string | null -} - -export interface CodexProviderOptions extends CodexRequestOptions { - authStore?: AuthStore - providerId?: string - fetch?: CodexFetchLike - version?: string - sessionId?: string - now?: () => number -} - -export interface CodexRequestBody { - model: string - input: Array> - conversation?: string | null - include?: string[] | null - instructions?: string - max_tool_calls?: number | null - metadata?: Record - parallel_tool_calls?: boolean | null - previous_response_id?: string | null - prompt_cache_key?: string | null - prompt_cache_retention?: string | null - reasoning?: { - effort?: string | null - summary?: string | null - } - service_tier?: string | null - store: false - stream: true - tool_choice?: string | { type: string; name?: string } | null - tools?: Array> - truncation?: string | null - user?: string | null -} - -export interface CodexModelOptions extends CodexProviderOptions { - modelId: string -} - -interface CodexResponseCreatedEvent { - type: 'response.created' - response: { - id: string - created_at: number - model: string - } -} - -interface CodexResponseTextDeltaEvent { - type: 'response.output_text.delta' - item_id: string - delta: string -} - -interface CodexResponseOutputItemAddedEvent { - type: 'response.output_item.added' - output_index: number - item: - | { - type: 'message' - id: string - phase?: 'commentary' | 'final_answer' | null - } - | { - type: 'reasoning' - id: string - encrypted_content?: string | null - } - | { - type: 'function_call' - id: string - call_id?: string - name?: string - arguments?: string - } -} - -interface CodexResponseOutputItemDoneEvent { - type: 'response.output_item.done' - output_index: number - item: - | { - type: 'message' - id: string - phase?: 'commentary' | 'final_answer' | null - } - | { - type: 'reasoning' - id: string - encrypted_content?: string | null - } - | { - type: 'function_call' - id: string - call_id?: string - name?: string - arguments?: string - } -} - -interface CodexResponseReasoningSummaryPartAddedEvent { - type: 'response.reasoning_summary_part.added' - item_id: string - summary_index: number -} - -interface CodexResponseReasoningSummaryTextDeltaEvent { - type: 'response.reasoning_summary_text.delta' - item_id: string - summary_index: number - delta: string -} - -interface CodexResponseFunctionCallArgumentsDeltaEvent { - type: 'response.function_call_arguments.delta' - item_id: string - output_index: number - delta: string -} - -interface CodexResponseFunctionCallArgumentsDoneEvent { - type: 'response.function_call_arguments.done' - item_id: string - output_index: number - arguments: string -} - -interface CodexResponseReasoningSummaryPartDoneEvent { - type: 'response.reasoning_summary_part.done' - item_id: string - summary_index: number -} - -interface CodexResponseFinishedEvent { - type: 'response.completed' | 'response.incomplete' | 'response.failed' - response: { - incomplete_details?: { reason?: string } | null - usage?: { - input_tokens: number - input_tokens_details?: { cached_tokens?: number | null } | null - output_tokens: number - output_tokens_details?: { reasoning_tokens?: number | null } | null - } | null - error?: { message: string } | null - } -} - -type CodexSseEvent = - | CodexResponseCreatedEvent - | CodexResponseTextDeltaEvent - | CodexResponseOutputItemAddedEvent - | CodexResponseOutputItemDoneEvent - | CodexResponseReasoningSummaryPartAddedEvent - | CodexResponseReasoningSummaryTextDeltaEvent - | CodexResponseReasoningSummaryPartDoneEvent - | CodexResponseFunctionCallArgumentsDeltaEvent - | CodexResponseFunctionCallArgumentsDoneEvent - | CodexResponseFinishedEvent - -export function createCodexProvider(options: CodexProviderOptions): ProviderV3 { - return { - specificationVersion: 'v3', - languageModel(modelId: string) { - return createCodexLanguageModel({ ...options, modelId }) - }, - embeddingModel(modelId: string) { - throw new NoSuchModelError({ modelId, modelType: 'embeddingModel' }) - }, - imageModel(modelId: string) { - throw new NoSuchModelError({ modelId, modelType: 'imageModel' }) - }, - transcriptionModel(modelId: string) { - throw new NoSuchModelError({ modelId, modelType: 'transcriptionModel' }) - }, - speechModel(modelId: string) { - throw new NoSuchModelError({ modelId, modelType: 'speechModel' }) - }, - rerankingModel(modelId: string) { - throw new NoSuchModelError({ modelId, modelType: 'rerankingModel' }) - }, - } -} - -export function createCodexLanguageModel(options: CodexModelOptions): LanguageModelV3 { - const providerId = options.providerId ?? CODEX_PROVIDER_ID - const authStore = options.authStore ?? createFileAuthStore() - const fetchFn = options.fetch ?? globalThis.fetch - const now = options.now ?? Date.now - - return { - specificationVersion: 'v3', - provider: CODEX_PROVIDER, - modelId: options.modelId, - supportedUrls: {}, - async doGenerate(callOptions) { - const prepared = await prepareCodexRequest({ - callOptions, - modelId: options.modelId, - requestOptions: options, - authStore, - providerId, - fetch: fetchFn, - version: options.version, - sessionId: options.sessionId, - now, - }) - - const response = await fetchFn(CODEX_API_ENDPOINT, { - method: 'POST', - headers: prepared.headers, - body: JSON.stringify(prepared.body), - signal: callOptions.abortSignal, - }) - - if (!response.ok) { - throw new Error(`Codex request failed: ${response.status} ${await response.text()}`) - } - - const streamed = await parseCodexSseResponse(response) - return streamPartsToGenerateResult(streamed.parts, prepared.body, response) - }, - async doStream(callOptions) { - const prepared = await prepareCodexRequest({ - callOptions, - modelId: options.modelId, - requestOptions: options, - authStore, - providerId, - fetch: fetchFn, - version: options.version, - sessionId: options.sessionId, - now, - }) - - const response = await fetchFn(CODEX_API_ENDPOINT, { - method: 'POST', - headers: prepared.headers, - body: JSON.stringify(prepared.body), - signal: callOptions.abortSignal, - }) - - if (!response.ok) { - throw new Error(`Codex request failed: ${response.status} ${await response.text()}`) - } - - return { - stream: createCodexSseStream(response), - request: { body: prepared.body }, - response: { headers: headersToRecord(response.headers) }, - } - }, - } -} - -export async function prepareCodexRequest(args: { - callOptions: LanguageModelV3CallOptions - modelId: string - requestOptions?: CodexRequestOptions - authStore: AuthStore - providerId: string - fetch: CodexFetchLike - version?: string - sessionId?: string - now: () => number -}): Promise<{ headers: Record; body: CodexRequestBody; auth: AuthInfo }> { - const auth = await resolveCodexAuth(args.authStore, args.providerId, args.fetch, args.now) - const body = buildCodexRequestBody(args.callOptions, args.modelId, args.requestOptions) - const headers = buildCodexHeaders({ - auth, - version: args.version, - sessionId: args.sessionId, - callerHeaders: args.callOptions.headers, - }) - return { headers, body, auth } -} - -export function buildCodexHeaders(args: { - auth: AuthInfo - version?: string - sessionId?: string - callerHeaders?: Record -}): Record { - const headers = new Headers() - - for (const [key, value] of Object.entries(args.callerHeaders ?? {})) { - if (value == null) continue - if (key.toLowerCase() === 'authorization') continue - headers.set(key, value) - } - - headers.set('content-type', 'application/json') - headers.set('authorization', `Bearer ${getAuthToken(args.auth)}`) - headers.set('originator', 'opencode') - headers.set('User-Agent', buildCodexUserAgent(args.version ?? CODEX_DEFAULT_VERSION)) - - if (args.sessionId) { - headers.set('session-id', args.sessionId) - } - - if (args.auth.kind === 'oauth' && args.auth.accountId) { - headers.set('ChatGPT-Account-Id', args.auth.accountId) - } - - return Object.fromEntries(headers.entries()) -} - -export function buildCodexUserAgent(version: string): string { - return `opencode/${version} (${os.platform()} ${os.release()}; ${os.arch()})` -} - -export function buildCodexRequestBody( - options: LanguageModelV3CallOptions, - modelId: string, - requestOptions: CodexRequestOptions = {}, -): CodexRequestBody { - const transformed = transformCodexPrompt(options.prompt) - const providerInstructions = getProviderInstructions(options) - const instructions = joinInstructions(transformed.instructions, providerInstructions) - - // Strip all `id` fields from input items. The Codex CLI (Rust) uses - // #[serde(skip_serializing)] on id for all item types. IDs are server-side - // identifiers that must not be sent back when store=false. - const input = transformed.input.map(({ id: _stripped, ...rest }) => rest) - - return { - model: modelId, - input, - ...(instructions ? { instructions } : {}), - ...buildCodexTools(options), - ...buildCodexRequestExtras(options, requestOptions), - store: false, - stream: true, - } -} - -export function buildCodexTools(options: LanguageModelV3CallOptions): Pick { - const tools = options.tools?.map((tool) => { - if (tool.type === 'provider') { - return { - type: tool.id, - name: tool.name, - ...(Object.keys(tool.args).length > 0 ? tool.args : {}), - } - } - - return { - type: 'function', - name: tool.name, - ...(tool.description ? { description: tool.description } : {}), - parameters: tool.inputSchema, - strict: tool.strict ?? false, - } - }) - - return { - ...(tools && tools.length > 0 ? { tools } : {}), - ...mapCodexToolChoice(options.toolChoice), - } -} - -export function transformCodexPrompt(prompt: LanguageModelV3Prompt): { - input: Array> - instructions?: string -} { - const input: Array> = [] - const instructions: string[] = [] - - for (const message of prompt) { - if (message.role === 'system') { - instructions.push(message.content) - continue - } - - if (message.role === 'user') { - input.push({ - role: 'user', - content: message.content - .filter((part) => part.type === 'text') - .map((part) => ({ type: 'input_text', text: part.text })), - }) - continue - } - - if (message.role === 'assistant') { - for (const part of message.content) { - if (part.type === 'reasoning') { - const reasoningInput = buildReasoningInput(part) - if (reasoningInput) { - input.push(reasoningInput) - } - continue - } - - if (part.type === 'text') { - const itemId = readItemIdFromProviderOptions(part.providerOptions) - input.push({ - role: 'assistant', - content: [{ type: 'output_text', text: part.text }], - ...(itemId !== undefined ? { id: itemId } : {}), - }) - continue - } - - if (part.type === 'tool-call') { - const itemId = readItemIdFromProviderOptions(part.providerOptions) - input.push({ - type: 'function_call', - call_id: part.toolCallId, - name: part.toolName, - arguments: typeof part.input === 'string' ? part.input : JSON.stringify(part.input), - ...(itemId !== undefined ? { id: itemId } : {}), - }) - } - } - continue - } - - if (message.role === 'tool') { - for (const part of message.content) { - if (part.type === 'tool-result') { - input.push({ - type: 'function_call_output', - call_id: part.toolCallId, - output: convertToolResultOutput(part.output), - }) - } - } - } - } - - return { - input, - ...(instructions.length > 0 ? { instructions: instructions.join('\n\n') } : {}), - } -} - -export async function resolveCodexAuth( - store: AuthStore, - providerId: string, - fetchFn: CodexFetchLike, - now: () => number, -): Promise { - const auth = await store.get(providerId) - if (!auth) { - throw new Error(`Missing auth for provider: ${providerId}`) - } - - if (auth.kind !== 'oauth') { - return auth - } - - if (!auth.refreshToken || !isExpired(auth, now())) { - return auth - } - - const refreshed = await refreshAccessToken(auth.refreshToken, fetchFn) - const updated: OAuthAuthInfo = { - ...auth, - accessToken: refreshed.access_token ?? auth.accessToken, - refreshToken: refreshed.refresh_token ?? auth.refreshToken, - idToken: refreshed.id_token ?? auth.idToken, - expiresAt: now() + (refreshed.expires_in ?? 3600) * 1000, - } - - await store.set(providerId, updated) - return updated -} - -export async function parseCodexSseResponse(response: Response): Promise<{ parts: LanguageModelV3StreamPart[] }> { - const stream = createCodexSseStream(response) - const reader = stream.getReader() - const parts: LanguageModelV3StreamPart[] = [] - - while (true) { - const { done, value } = await reader.read() - if (done) break - parts.push(value) - } - - return { parts } -} - -/** Default timeout for SSE stream reads (2 minutes) */ -const STREAM_READ_TIMEOUT_MS = 120_000 - -/** - * Read from a stream reader with a timeout. - * If no data is received within timeoutMs, throws an error to prevent indefinite hangs. - */ -async function readWithTimeout( - reader: { read(): Promise<{ done: boolean; value?: Uint8Array }> }, - timeoutMs: number, -): Promise<{ done: boolean; value?: Uint8Array }> { - let timeoutId: ReturnType | undefined - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - reject(new Error(`SSE stream read timed out after ${timeoutMs}ms - no data received`)) - }, timeoutMs) - }) - - try { - return await Promise.race([reader.read(), timeoutPromise]) - } finally { - if (timeoutId) clearTimeout(timeoutId) - } -} - -export function createCodexSseStream(response: Response): ReadableStream { - if (!response.body) { - throw new Error('Missing Codex response body') - } - - const decoder = new TextDecoder() - - return new ReadableStream({ - async start(controller) { - const reader = response.body?.getReader() - if (!reader) { - controller.error(new Error('Missing Codex response body')) - return - } - - let buffer = '' - let finishSeen = false - let hasFunctionCall = false - const activeReasoning = new Map< - number, - { canonicalId: string; encryptedContent?: string | null; summaryParts: Set } - >() - const activeFunctionCalls = new Map() - let responseId: string | undefined - controller.enqueue({ type: 'stream-start', warnings: [] }) - - try { - while (true) { - const { done, value } = await readWithTimeout(reader, STREAM_READ_TIMEOUT_MS) - if (done) break - buffer += decoder.decode(value, { stream: true }) - - const parsed = parseCodexSseBuffer(buffer) - buffer = parsed.remainder - - for (const event of parsed.events) { - if (event.type === 'response.created') { - responseId = event.response.id - } - if (event.type === 'response.output_item.done' && event.item.type === 'function_call') { - hasFunctionCall = true - } - - const streamParts = codexEventToStreamParts( - event, - hasFunctionCall, - responseId, - activeReasoning, - activeFunctionCalls, - ) - for (const streamPart of streamParts) { - controller.enqueue(streamPart) - if (streamPart.type === 'finish') { - finishSeen = true - } - } - } - } - - buffer += decoder.decode() - const trailing = parseCodexSseBuffer(buffer) - for (const event of trailing.events) { - if (event.type === 'response.created') { - responseId = event.response.id - } - if (event.type === 'response.output_item.done' && event.item.type === 'function_call') { - hasFunctionCall = true - } - - const streamParts = codexEventToStreamParts( - event, - hasFunctionCall, - responseId, - activeReasoning, - activeFunctionCalls, - ) - for (const streamPart of streamParts) { - controller.enqueue(streamPart) - if (streamPart.type === 'finish') { - finishSeen = true - } - } - } - - if (!finishSeen) { - controller.enqueue({ - type: 'finish', - finishReason: { unified: 'stop', raw: undefined }, - usage: mapCodexUsage(), - }) - } - controller.close() - } catch (error) { - controller.error(error) - } finally { - reader.releaseLock() - } - }, - }) -} - -export function streamPartsToGenerateResult( - parts: LanguageModelV3StreamPart[], - requestBody: CodexRequestBody, - response: Response, -): LanguageModelV3GenerateResult { - const textBuffers = new Map() - const reasoningBuffers = new Map() - const content: LanguageModelV3Content[] = [] - let finishReason: LanguageModelV3FinishReason = { unified: 'stop', raw: undefined } - let usage: LanguageModelV3Usage = mapCodexUsage() - let responseMetadata: { id?: string; timestamp?: Date; modelId?: string } = {} - - for (const part of parts) { - if (part.type === 'text-start') { - textBuffers.set(part.id, '') - continue - } - - if (part.type === 'reasoning-start') { - reasoningBuffers.set(part.id, '') - continue - } - - if (part.type === 'text-delta') { - textBuffers.set(part.id, `${textBuffers.get(part.id) ?? ''}${part.delta}`) - continue - } - - if (part.type === 'reasoning-delta') { - reasoningBuffers.set(part.id, `${reasoningBuffers.get(part.id) ?? ''}${part.delta}`) - continue - } - - if (part.type === 'text-end') { - content.push({ - type: 'text', - text: textBuffers.get(part.id) ?? '', - providerMetadata: part.providerMetadata, - }) - continue - } - - if (part.type === 'reasoning-end') { - content.push({ - type: 'reasoning', - text: reasoningBuffers.get(part.id) ?? '', - providerMetadata: part.providerMetadata, - }) - continue - } - - if (part.type === 'response-metadata') { - responseMetadata = { - id: part.id, - timestamp: part.timestamp, - modelId: part.modelId, - } - continue - } - - if (part.type === 'finish') { - finishReason = part.finishReason - usage = part.usage - } - } - - return { - content, - finishReason, - usage, - providerMetadata: responseMetadata?.id ? { openai: { responseId: responseMetadata.id } } : undefined, - warnings: [], - request: { body: requestBody }, - response: { - ...responseMetadata, - headers: headersToRecord(response.headers), - }, - } -} - -function parseCodexSseBuffer(buffer: string): { events: CodexSseEvent[]; remainder: string } { - const events: CodexSseEvent[] = [] - let remainder = buffer - - while (true) { - const boundary = remainder.indexOf('\n\n') - if (boundary === -1) { - return { events, remainder } - } - - const rawEvent = remainder.slice(0, boundary) - remainder = remainder.slice(boundary + 2) - const event = parseSseEvent(rawEvent) - if (event) { - events.push(event) - } - } -} - -function codexEventToStreamParts( - event: CodexSseEvent, - hasFunctionCall: boolean, - responseId: string | undefined, - activeReasoning: Map }>, - activeFunctionCalls: Map, -): LanguageModelV3StreamPart[] { - if (event.type === 'response.created') { - return [ - { - type: 'response-metadata', - id: event.response.id, - timestamp: new Date(event.response.created_at * 1000), - modelId: event.response.model, - }, - ] - } - - if (event.type === 'response.output_item.added' && event.item.type === 'message') { - return [ - { - type: 'text-start', - id: event.item.id, - providerMetadata: buildItemProviderMetadata(event.item.id, event.item.phase, responseId), - }, - ] - } - - if (event.type === 'response.output_item.added' && event.item.type === 'reasoning') { - activeReasoning.set(event.output_index, { - canonicalId: event.item.id, - encryptedContent: event.item.encrypted_content, - summaryParts: new Set([0]), - }) - return [ - { - type: 'reasoning-start', - id: `${event.item.id}:0`, - providerMetadata: buildReasoningProviderMetadata( - event.item.id, - event.item.encrypted_content, - responseId, - ), - }, - ] - } - - if (event.type === 'response.output_item.added' && event.item.type === 'function_call') { - const toolCallId = event.item.call_id ?? event.item.id - const toolName = event.item.name ?? '' - activeFunctionCalls.set(event.output_index, { itemId: event.item.id, toolCallId, toolName }) - return [{ type: 'tool-input-start', id: toolCallId, toolName }] - } - - if (event.type === 'response.function_call_arguments.delta') { - const call = activeFunctionCalls.get(event.output_index) - return call ? [{ type: 'tool-input-delta', id: call.toolCallId, delta: event.delta }] : [] - } - - if (event.type === 'response.function_call_arguments.done') { - const call = activeFunctionCalls.get(event.output_index) - return call ? [{ type: 'tool-input-end', id: call.toolCallId }] : [] - } - - if (event.type === 'response.output_text.delta') { - return [{ type: 'text-delta', id: event.item_id, delta: event.delta }] - } - - if (event.type === 'response.reasoning_summary_part.added') { - const reasoning = findActiveReasoningByCanonicalId(activeReasoning, event.item_id) - if (!reasoning) { - return [] - } - reasoning.summaryParts.add(event.summary_index) - if (event.summary_index === 0) { - return [] - } - return [ - { - type: 'reasoning-start', - id: `${event.item_id}:${event.summary_index}`, - providerMetadata: buildReasoningProviderMetadata(event.item_id, reasoning.encryptedContent, responseId), - }, - ] - } - - if (event.type === 'response.reasoning_summary_text.delta') { - return [ - { - type: 'reasoning-delta', - id: `${event.item_id}:${event.summary_index}`, - delta: event.delta, - providerMetadata: buildReasoningProviderMetadata(event.item_id, undefined, responseId), - }, - ] - } - - if (event.type === 'response.reasoning_summary_part.done') { - const reasoning = findActiveReasoningByCanonicalId(activeReasoning, event.item_id) - if (!reasoning || !reasoning.summaryParts.has(event.summary_index)) { - return [] - } - return [] - } - - if (event.type === 'response.output_item.done' && event.item.type === 'message') { - return [ - { - type: 'text-end', - id: event.item.id, - providerMetadata: buildItemProviderMetadata(event.item.id, event.item.phase, responseId), - }, - ] - } - - if (event.type === 'response.output_item.done' && event.item.type === 'reasoning') { - const reasoning = activeReasoning.get(event.output_index) - if (!reasoning) { - return [] - } - const item = event.item - const encryptedContent = item.encrypted_content ?? reasoning.encryptedContent - activeReasoning.delete(event.output_index) - return [...reasoning.summaryParts] - .sort((left, right) => left - right) - .map((summaryIndex) => ({ - type: 'reasoning-end' as const, - id: `${reasoning.canonicalId}:${summaryIndex}`, - providerMetadata: buildReasoningProviderMetadata(reasoning.canonicalId, encryptedContent, responseId), - })) - } - - if (event.type === 'response.output_item.done' && event.item.type === 'function_call') { - const call = activeFunctionCalls.get(event.output_index) - activeFunctionCalls.delete(event.output_index) - const toolCallId = event.item.call_id ?? call?.toolCallId ?? event.item.id - const toolName = event.item.name ?? call?.toolName ?? '' - return [ - { - type: 'tool-call', - toolCallId, - toolName, - input: event.item.arguments ?? '{}', - providerMetadata: buildItemProviderMetadata(event.item.id, undefined, responseId), - }, - ] - } - - if ( - event.type === 'response.completed' || - event.type === 'response.incomplete' || - event.type === 'response.failed' - ) { - return [ - { - type: 'finish', - finishReason: mapCodexFinishReason(event.response.incomplete_details?.reason, hasFunctionCall), - usage: mapCodexUsage(event.response.usage ?? undefined), - providerMetadata: responseId !== undefined ? { openai: { responseId } } : undefined, - }, - ] - } - - return [] -} - -function parseSseEvent(rawEvent: string): CodexSseEvent | undefined { - const dataLines = rawEvent - .split('\n') - .filter((line) => line.startsWith('data:')) - .map((line) => line.slice(5).trim()) - - if (dataLines.length === 0) return undefined - const payload = dataLines.join('\n') - if (!payload || payload === '[DONE]') return undefined - - const parsed = JSON.parse(payload) as { type?: unknown } - if (typeof parsed.type !== 'string') return undefined - - switch (parsed.type) { - case 'response.created': - return isCreatedEvent(parsed) ? parsed : undefined - case 'response.output_text.delta': - return isTextDeltaEvent(parsed) ? parsed : undefined - case 'response.output_item.added': - return isOutputItemAddedEvent(parsed) ? parsed : undefined - case 'response.output_item.done': - return isOutputItemDoneEvent(parsed) ? parsed : undefined - case 'response.reasoning_summary_part.added': - return isReasoningSummaryPartAddedEvent(parsed) ? parsed : undefined - case 'response.reasoning_summary_text.delta': - return isReasoningSummaryTextDeltaEvent(parsed) ? parsed : undefined - case 'response.reasoning_summary_part.done': - return isReasoningSummaryPartDoneEvent(parsed) ? parsed : undefined - case 'response.function_call_arguments.delta': - return isFunctionCallArgumentsDeltaEvent(parsed) ? parsed : undefined - case 'response.function_call_arguments.done': - return isFunctionCallArgumentsDoneEvent(parsed) ? parsed : undefined - case 'response.completed': - case 'response.incomplete': - case 'response.failed': - return isFinishedEvent(parsed) ? parsed : undefined - default: - return undefined - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -function isCreatedEvent(value: unknown): value is CodexResponseCreatedEvent { - if (!isRecord(value) || !isRecord(value.response)) return false - return ( - typeof value.response.id === 'string' && - typeof value.response.created_at === 'number' && - typeof value.response.model === 'string' - ) -} - -function isTextDeltaEvent(value: unknown): value is CodexResponseTextDeltaEvent { - if (!isRecord(value)) return false - return typeof value.item_id === 'string' && typeof value.delta === 'string' -} - -function isOutputItemAddedEvent(value: unknown): value is CodexResponseOutputItemAddedEvent { - if (!isRecord(value) || !isRecord(value.item)) return false - return typeof value.item.id === 'string' && typeof value.item.type === 'string' -} - -function isOutputItemDoneEvent(value: unknown): value is CodexResponseOutputItemDoneEvent { - if (!isRecord(value) || !isRecord(value.item)) return false - return typeof value.item.id === 'string' && typeof value.item.type === 'string' -} - -function isReasoningSummaryPartAddedEvent(value: unknown): value is CodexResponseReasoningSummaryPartAddedEvent { - if (!isRecord(value)) return false - return typeof value.item_id === 'string' && typeof value.summary_index === 'number' -} - -function isReasoningSummaryTextDeltaEvent(value: unknown): value is CodexResponseReasoningSummaryTextDeltaEvent { - if (!isRecord(value)) return false - return ( - typeof value.item_id === 'string' && typeof value.summary_index === 'number' && typeof value.delta === 'string' - ) -} - -function isReasoningSummaryPartDoneEvent(value: unknown): value is CodexResponseReasoningSummaryPartDoneEvent { - if (!isRecord(value)) return false - return typeof value.item_id === 'string' && typeof value.summary_index === 'number' -} - -function isFunctionCallArgumentsDeltaEvent(value: unknown): value is CodexResponseFunctionCallArgumentsDeltaEvent { - if (!isRecord(value)) return false - return ( - typeof value.item_id === 'string' && typeof value.output_index === 'number' && typeof value.delta === 'string' - ) -} - -function isFunctionCallArgumentsDoneEvent(value: unknown): value is CodexResponseFunctionCallArgumentsDoneEvent { - if (!isRecord(value)) return false - return ( - typeof value.item_id === 'string' && - typeof value.output_index === 'number' && - typeof value.arguments === 'string' - ) -} - -function isFinishedEvent(value: unknown): value is CodexResponseFinishedEvent { - return isRecord(value) && isRecord(value.response) -} - -function getProviderInstructions(options: LanguageModelV3CallOptions): string | undefined { - const openai = getCodexProviderOptionRecord(options, 'openai') - if (typeof openai?.instructions === 'string') { - return openai.instructions - } - - const codex = getCodexProviderOptionRecord(options, 'codex') - if (typeof codex?.instructions === 'string') { - return codex.instructions - } - return undefined -} - -function getCodexProviderOptionRecord( - options: LanguageModelV3CallOptions, - providerName: 'openai' | 'codex', -): Record | undefined { - let providerOptions = options.providerOptions?.[providerName] - if (!providerOptions && providerName === 'openai') { - providerOptions = options.providerOptions?.openaiCompatible - } - return isRecord(providerOptions) ? providerOptions : undefined -} - -function getNullableString(value: Record | undefined, key: string): string | null | undefined { - if (!value || !(key in value)) { - return undefined - } - const candidate = value[key] - return typeof candidate === 'string' || candidate === null ? candidate : undefined -} - -function getNullableBoolean(value: Record | undefined, key: string): boolean | null | undefined { - if (!value || !(key in value)) { - return undefined - } - const candidate = value[key] - return typeof candidate === 'boolean' || candidate === null ? candidate : undefined -} - -function getNullableNumber(value: Record | undefined, key: string): number | null | undefined { - if (!value || !(key in value)) { - return undefined - } - const candidate = value[key] - return typeof candidate === 'number' || candidate === null ? candidate : undefined -} - -function getNullableStringArray(value: Record | undefined, key: string): string[] | null | undefined { - if (!value || !(key in value)) { - return undefined - } - const candidate = value[key] - if (candidate === null) { - return null - } - if (Array.isArray(candidate) && candidate.every((entry) => typeof entry === 'string')) { - return [...candidate] - } - return undefined -} - -function getMetadataRecord( - value: Record | undefined, - key: string, -): Record | undefined { - if (!value || !(key in value)) { - return undefined - } - const candidate = value[key] - return isRecord(candidate) ? candidate : undefined -} - -function buildCodexReasoningOptions(options: LanguageModelV3CallOptions): CodexRequestBody['reasoning'] | undefined { - const openai = getCodexProviderOptionRecord(options, 'openai') - const codex = getCodexProviderOptionRecord(options, 'codex') - const effort = getNullableString(openai, 'reasoningEffort') ?? getNullableString(codex, 'reasoningEffort') - const summary = getNullableString(openai, 'reasoningSummary') ?? getNullableString(codex, 'reasoningSummary') - - if (effort == null && summary == null) { - return undefined - } - - return { - ...(effort != null ? { effort } : {}), - ...(summary != null ? { summary } : {}), - } -} - -function getNullableFastMode(value: Record | undefined): boolean | null | undefined { - if (!value || !('fastMode' in value)) { - return undefined - } - const candidate = value.fastMode - return typeof candidate === 'boolean' || candidate === null ? candidate : undefined -} - -export function normalizeCodexServiceTier(serviceTier: string | null | undefined): string | null | undefined { - if (serviceTier == null) { - return serviceTier - } - return serviceTier === 'fast' ? CODEX_FAST_SERVICE_TIER : serviceTier -} - -function buildCodexServiceTier( - options: LanguageModelV3CallOptions, - requestOptions: CodexRequestOptions, -): string | null | undefined { - const openai = getCodexProviderOptionRecord(options, 'openai') - const codex = getCodexProviderOptionRecord(options, 'codex') - const serviceTier = - getNullableString(openai, 'serviceTier') ?? - getNullableString(codex, 'serviceTier') ?? - requestOptions.serviceTier - - if (serviceTier !== undefined) { - return normalizeCodexServiceTier(serviceTier) - } - - const fastMode = getNullableFastMode(openai) ?? getNullableFastMode(codex) ?? requestOptions.fastMode - if (fastMode === true) { - return CODEX_FAST_SERVICE_TIER - } - if (fastMode === false || fastMode === null) { - return undefined - } - - return undefined -} - -function buildCodexRequestExtras( - options: LanguageModelV3CallOptions, - requestOptions: CodexRequestOptions, -): Omit { - const openai = getCodexProviderOptionRecord(options, 'openai') - const codex = getCodexProviderOptionRecord(options, 'codex') - const include = getNullableStringArray(openai, 'include') ?? - getNullableStringArray(codex, 'include') ?? ['reasoning.encrypted_content'] - const reasoning = buildCodexReasoningOptions(options) - const conversation = getNullableString(openai, 'conversation') ?? getNullableString(codex, 'conversation') - const maxToolCalls = getNullableNumber(openai, 'maxToolCalls') ?? getNullableNumber(codex, 'maxToolCalls') - const metadata = getMetadataRecord(openai, 'metadata') ?? getMetadataRecord(codex, 'metadata') - const parallelToolCalls = - getNullableBoolean(openai, 'parallelToolCalls') ?? getNullableBoolean(codex, 'parallelToolCalls') - const promptCacheKey = getNullableString(openai, 'promptCacheKey') ?? getNullableString(codex, 'promptCacheKey') - const promptCacheRetention = - getNullableString(openai, 'promptCacheRetention') ?? getNullableString(codex, 'promptCacheRetention') - const serviceTier = buildCodexServiceTier(options, requestOptions) - const truncation = getNullableString(openai, 'truncation') ?? getNullableString(codex, 'truncation') - const user = getNullableString(openai, 'user') ?? getNullableString(codex, 'user') - - return { - ...(conversation !== undefined ? { conversation } : {}), - ...(include !== undefined ? { include } : {}), - ...(maxToolCalls !== undefined ? { max_tool_calls: maxToolCalls } : {}), - ...(metadata ? { metadata } : {}), - ...(parallelToolCalls !== undefined ? { parallel_tool_calls: parallelToolCalls } : {}), - ...(promptCacheKey !== undefined ? { prompt_cache_key: promptCacheKey } : {}), - ...(promptCacheRetention !== undefined ? { prompt_cache_retention: promptCacheRetention } : {}), - ...(reasoning ? { reasoning } : {}), - ...(serviceTier !== undefined ? { service_tier: serviceTier } : {}), - ...(truncation !== undefined ? { truncation } : {}), - ...(user !== undefined ? { user } : {}), - } -} - -function readItemIdFromProviderOptions(value: unknown): string | undefined { - if (!isRecord(value)) { - return undefined - } - - const openai = isRecord(value.openai) ? value.openai : undefined - const codex = isRecord(value.codex) ? value.codex : undefined - return typeof openai?.itemId === 'string' - ? openai.itemId - : typeof codex?.itemId === 'string' - ? codex.itemId - : undefined -} - -function buildReasoningInput(part: { - text: string - providerOptions?: Record - providerMetadata?: Record -}): Record | undefined { - const openai = - readReasoningProviderOptions(part.providerOptions?.openai) ?? - readReasoningProviderOptions(part.providerMetadata?.openai) - const codex = - readReasoningProviderOptions(part.providerOptions?.codex) ?? - readReasoningProviderOptions(part.providerMetadata?.codex) - const itemId = openai?.itemId ?? codex?.itemId - const reasoningEncryptedContent = openai?.reasoningEncryptedContent ?? codex?.reasoningEncryptedContent - const summary = part.text.length > 0 ? [{ type: 'summary_text', text: part.text }] : [] - - if (itemId) { - return { - type: 'reasoning', - id: itemId, - ...(reasoningEncryptedContent !== undefined ? { encrypted_content: reasoningEncryptedContent } : {}), - summary, - } - } - - if (reasoningEncryptedContent !== undefined) { - return { - type: 'reasoning', - encrypted_content: reasoningEncryptedContent, - summary, - } - } - - return undefined -} - -function readReasoningProviderOptions( - value: unknown, -): { itemId?: string; reasoningEncryptedContent?: string | null } | undefined { - if (!isRecord(value)) { - return undefined - } - - const itemId = typeof value.itemId === 'string' ? value.itemId : undefined - const encrypted = - typeof value.reasoningEncryptedContent === 'string' || value.reasoningEncryptedContent === null - ? value.reasoningEncryptedContent - : undefined - if (itemId === undefined && encrypted === undefined) { - return undefined - } - - return { - ...(itemId !== undefined ? { itemId } : {}), - ...(encrypted !== undefined ? { reasoningEncryptedContent: encrypted } : {}), - } -} - -function joinInstructions(...values: Array): string | undefined { - const parts = values.filter((value): value is string => Boolean(value?.trim())) - return parts.length > 0 ? parts.join('\n\n') : undefined -} - -function mapCodexToolChoice( - toolChoice: LanguageModelV3CallOptions['toolChoice'], -): Pick { - if (!toolChoice || toolChoice.type === 'auto') return {} - if (toolChoice.type === 'none') return { tool_choice: 'none' } - if (toolChoice.type === 'required') return { tool_choice: 'required' } - return { tool_choice: { type: 'function', name: toolChoice.toolName } } -} - -function convertToolResultOutput(output: unknown): unknown { - if (!output || typeof output !== 'object') { - return JSON.stringify(output) - } - - if ('type' in output && output.type === 'text' && 'value' in output && typeof output.value === 'string') { - return output.value - } - - if ('type' in output && output.type === 'json' && 'value' in output) { - return JSON.stringify(output.value) - } - - if ('type' in output && output.type === 'content' && 'value' in output && Array.isArray(output.value)) { - return output.value - .map((item) => { - if (!item || typeof item !== 'object' || !('type' in item)) { - return undefined - } - - if (item.type === 'text' && 'text' in item && typeof item.text === 'string') { - return { type: 'input_text', text: item.text } - } - - if ( - item.type === 'image-data' && - 'data' in item && - typeof item.data === 'string' && - 'mediaType' in item && - typeof item.mediaType === 'string' - ) { - return { type: 'input_image', image_url: `data:${item.mediaType};base64,${item.data}` } - } - - if (item.type === 'image-url' && 'url' in item && typeof item.url === 'string') { - return { type: 'input_image', image_url: item.url } - } - - if ( - item.type === 'file-data' && - 'data' in item && - typeof item.data === 'string' && - 'mediaType' in item && - typeof item.mediaType === 'string' - ) { - return { - type: 'input_file', - filename: 'filename' in item && typeof item.filename === 'string' ? item.filename : 'data', - file_data: `data:${item.mediaType};base64,${item.data}`, - } - } - - if (item.type === 'file-url' && 'url' in item && typeof item.url === 'string') { - return { type: 'input_file', file_url: item.url } - } - - return undefined - }) - .filter((item) => item !== undefined) - } - - return JSON.stringify(output) -} - -function getAuthToken(auth: AuthInfo): string { - return auth.kind === 'api' ? auth.apiKey : auth.accessToken -} - -function isExpired(auth: OAuthAuthInfo, now: number): boolean { - return auth.expiresAt != null && auth.expiresAt <= now -} - -function findActiveReasoningByCanonicalId( - activeReasoning: Map }>, - itemId: string, -): { canonicalId: string; encryptedContent?: string | null; summaryParts: Set } | undefined { - for (const reasoning of activeReasoning.values()) { - if (reasoning.canonicalId === itemId) { - return reasoning - } - } - return undefined -} - -function buildItemProviderMetadata( - itemId: string, - phase?: 'commentary' | 'final_answer' | null, - responseId?: string, -): SharedV3ProviderMetadata { - return { - openai: { - itemId, - ...(phase != null ? { phase } : {}), - ...(responseId !== undefined ? { responseId } : {}), - }, - } -} - -function buildReasoningProviderMetadata( - itemId: string, - reasoningEncryptedContent?: string | null, - responseId?: string, -): SharedV3ProviderMetadata { - return { - openai: { - itemId, - ...(reasoningEncryptedContent !== undefined ? { reasoningEncryptedContent } : {}), - ...(responseId !== undefined ? { responseId } : {}), - }, - } -} - -function mapCodexFinishReason(reason?: string, hasFunctionCall = false): LanguageModelV3FinishReason { - if (!reason) { - return { unified: hasFunctionCall ? 'tool-calls' : 'stop', raw: undefined } - } - - if (reason === 'max_output_tokens') { - return { unified: 'length', raw: reason } - } - - if (reason === 'content_filter') { - return { unified: 'content-filter', raw: reason } - } - - return { unified: hasFunctionCall ? 'tool-calls' : 'other', raw: reason } -} - -function mapCodexUsage(usage?: CodexResponseFinishedEvent['response']['usage']): LanguageModelV3Usage { - const inputTotal = usage?.input_tokens - const cacheRead = usage?.input_tokens_details?.cached_tokens ?? undefined - const outputTotal = usage?.output_tokens - const reasoning = usage?.output_tokens_details?.reasoning_tokens ?? undefined - const text = outputTotal != null ? Math.max(outputTotal - (reasoning ?? 0), 0) : undefined - - return { - inputTokens: { - total: inputTotal, - noCache: inputTotal != null ? Math.max(inputTotal - (cacheRead ?? 0), 0) : undefined, - cacheRead, - cacheWrite: undefined, - }, - outputTokens: { - total: outputTotal, - text, - reasoning, - }, - } -} - -function headersToRecord(headers: Headers): Record { - return Object.fromEntries(headers.entries()) -} diff --git a/packages/agentlayer-provider-openai-codex/src/providers/aisdk-codex-provider/index.ts b/packages/agentlayer-provider-openai-codex/src/providers/aisdk-codex-provider/index.ts deleted file mode 100644 index 3143664..0000000 --- a/packages/agentlayer-provider-openai-codex/src/providers/aisdk-codex-provider/index.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { createOpenAI } from '@ai-sdk/openai' -import { NoSuchModelError, type ProviderV3 } from '@ai-sdk/provider' -import { createFileAuthStore } from '@humanlayer/agentlayer-provider-auth' -import type { CodexFetchLike } from '../../oauth' -import { buildCodexUserAgent, resolveCodexAuth } from '../../shared/auth' -import { - CODEX_API_ENDPOINT, - CODEX_DEFAULT_VERSION, - CODEX_FAST_SERVICE_TIER, - CODEX_HEADER_TIMEOUT_MS, - DEFAULT_CHUNK_TIMEOUT_MS, -} from '../../shared/constants' -import { normalizeCodexServiceTier } from '../../shared/service-tier' -import { wrapSSE } from '../../shared/sse' -import type { CodexDiagnosticRecord, CodexProviderOptions } from '../../shared/types' - -export interface CodexResponsesProviderOptions extends CodexProviderOptions { - /** - * Timeout in milliseconds between streamed SSE chunks. If no chunk arrives - * within this window, the request is aborted. Set to 0 or false to disable. - * @default 120000 (2 minutes) - */ - chunkTimeout?: number | false - /** - * Timeout in milliseconds for receiving the initial response headers from - * the server. If headers are not received within this window, the request - * is aborted. Set to 0 or false to disable. - * @default 10000 (10 seconds) - */ - headerTimeout?: number | false -} - -/** - * Creates a thin Codex provider that delegates SSE parsing to upstream - * `@ai-sdk/openai.responses()`. Only auth, headers, URL rewriting, and - * request body cleanup are handled here. - */ -export function createCodexResponsesProvider(options: CodexResponsesProviderOptions = {}): ProviderV3 { - const authStore = options.authStore ?? createFileAuthStore() - const providerId = options.providerId ?? 'codex' - const version = options.version ?? CODEX_DEFAULT_VERSION - const fetchFn: CodexFetchLike = options.fetch ?? globalThis.fetch - const now = options.now ?? Date.now - const chunkTimeout = options.chunkTimeout === false ? 0 : (options.chunkTimeout ?? DEFAULT_CHUNK_TIMEOUT_MS) - const headerTimeout = options.headerTimeout === false ? 0 : (options.headerTimeout ?? CODEX_HEADER_TIMEOUT_MS) - const diagnostics = options.diagnostics - - const emit = (event: string, severity: CodexDiagnosticRecord['severity'], metadata: Record) => { - diagnostics?.onEvent({ - event, - severity, - transport: 'aisdk_responses', - annotations: diagnostics.annotations, - metadata, - }) - } - - const codexFetch: CodexFetchLike = async (input, init): Promise => { - let auth: Awaited> - try { - auth = await resolveCodexAuth(authStore, providerId, fetchFn, now) - } catch (error) { - emit('codex.provider.auth.failed', 'error', { - terminal: true, - error: error instanceof Error ? error.message : String(error), - }) - throw error - } - - const headers = new Headers(init?.headers) - - // Strip any dummy authorization header that @ai-sdk/openai may have added - headers.delete('authorization') - - // Set real Codex auth - const token = auth.kind === 'api' ? auth.apiKey : auth.accessToken - headers.set('authorization', `Bearer ${token}`) - headers.set('originator', 'opencode') - headers.set('User-Agent', buildCodexUserAgent(version)) - - if (options.sessionId) { - headers.set('session-id', options.sessionId) - } - - if (auth.kind === 'oauth' && auth.accountId) { - headers.set('ChatGPT-Account-Id', auth.accountId) - } - - // Transform request body for Codex requirements - let body = init?.body - if (body && init?.method === 'POST') { - const parsed = JSON.parse(body as string) - - // Force Codex defaults - parsed.store = false - parsed.include = parsed.include ?? ['reasoning.encrypted_content'] - - // Remove fields Codex rejects - delete parsed.previous_response_id - delete parsed.max_output_tokens - - // Normalize service_tier - if (parsed.service_tier !== undefined) { - parsed.service_tier = normalizeCodexServiceTier(parsed.service_tier) - } - - // Apply fastMode if set at provider level and not overridden - if (options.fastMode && parsed.service_tier == null) { - parsed.service_tier = CODEX_FAST_SERVICE_TIER - } - - // Extract system messages from input and move to instructions field. - // Codex requires instructions to be set, but upstream SDK puts system - // messages in the input array. - if (Array.isArray(parsed.input)) { - const systemTexts: string[] = [] - const nonSystemInput: unknown[] = [] - - for (const item of parsed.input) { - const role = (item as { role?: string }).role - if (role === 'system') { - const content = (item as { content?: unknown }).content - if (Array.isArray(content)) { - for (const part of content) { - if ((part as { type?: string }).type === 'input_text') { - const text = (part as { text?: string }).text - if (text) systemTexts.push(text) - } - } - } else if (typeof content === 'string') { - systemTexts.push(content) - } - } else { - nonSystemInput.push(item) - } - } - - if (systemTexts.length > 0) { - parsed.instructions = systemTexts.join('\n\n') - parsed.input = nonSystemInput - } - } - - // Codex requires instructions - provide empty string as fallback - if (parsed.instructions == null) { - parsed.instructions = '' - } - - // Strip id fields from input items (Codex rejects them when store=false) - if (Array.isArray(parsed.input)) { - for (const item of parsed.input) { - if ('id' in item) { - delete item.id - } - } - } - - body = JSON.stringify(parsed) - } - - // Rewrite URL to Codex endpoint - const url = new URL(input.toString()) - const finalUrl = url.pathname.includes('/v1/responses') ? CODEX_API_ENDPOINT : url.toString() - - // Set up chunk timeout abort controller if enabled - const chunkAbortCtl = chunkTimeout > 0 ? new AbortController() : undefined - - // Set up header timeout abort controller if enabled - const headerAbortCtl = headerTimeout > 0 ? new AbortController() : undefined - let headerTimeoutId: ReturnType | undefined - if (headerAbortCtl) { - headerTimeoutId = setTimeout(() => { - emit('codex.provider.fetch.header_timeout', 'error', { - terminal: true, - timeoutMs: headerTimeout, - url: finalUrl, - }) - headerAbortCtl.abort() - }, headerTimeout) - } - - // Combine signals: caller's signal + chunk timeout + header timeout - const signals: AbortSignal[] = [] - if (init?.signal) signals.push(init.signal) - if (chunkAbortCtl) signals.push(chunkAbortCtl.signal) - if (headerAbortCtl) signals.push(headerAbortCtl.signal) - - const combinedSignal = - signals.length === 0 ? undefined : signals.length === 1 ? signals[0] : AbortSignal.any(signals) - - let res: Response - try { - res = await fetchFn(finalUrl, { ...init, headers, body, signal: combinedSignal }) - } catch (error) { - const isAbort = error instanceof DOMException && error.name === 'AbortError' - emit('codex.provider.fetch.failed', 'error', { - terminal: true, - error: error instanceof Error ? error.message : String(error), - isAbort, - url: finalUrl, - }) - throw error - } finally { - if (headerTimeoutId) clearTimeout(headerTimeoutId) - } - - if (!res.ok) { - emit('codex.provider.fetch.http_error', 'error', { - terminal: false, - status: res.status, - statusText: res.statusText, - url: finalUrl, - }) - } - - // Wrap SSE responses with per-chunk timeout watchdog - if (!chunkAbortCtl) return res - return wrapSSE(res, chunkTimeout, chunkAbortCtl, () => { - emit('codex.provider.fetch.chunk_timeout', 'error', { - terminal: true, - timeoutMs: chunkTimeout, - url: finalUrl, - }) - }) - } - - const openai = createOpenAI({ - apiKey: 'codex-oauth-placeholder', // stripped by codexFetch - fetch: codexFetch as typeof fetch, - }) - - return { - specificationVersion: 'v3', - - languageModel(modelId: string) { - const model = openai.responses(modelId) - return new Proxy(model, { - get: (target, prop, receiver) => - prop === 'provider' ? 'codex.responses' : Reflect.get(target, prop, receiver), - }) - }, - - embeddingModel(modelId: string) { - throw new NoSuchModelError({ modelId, modelType: 'embeddingModel' }) - }, - - imageModel(modelId: string) { - throw new NoSuchModelError({ modelId, modelType: 'imageModel' }) - }, - - transcriptionModel(modelId: string) { - throw new NoSuchModelError({ modelId, modelType: 'transcriptionModel' }) - }, - - speechModel(modelId: string) { - throw new NoSuchModelError({ modelId, modelType: 'speechModel' }) - }, - - rerankingModel(modelId: string) { - throw new NoSuchModelError({ modelId, modelType: 'rerankingModel' }) - }, - } -} diff --git a/packages/agentlayer-provider-openai-codex/src/shared/constants.ts b/packages/agentlayer-provider-openai-codex/src/shared/constants.ts index 736710f..0d1f341 100644 --- a/packages/agentlayer-provider-openai-codex/src/shared/constants.ts +++ b/packages/agentlayer-provider-openai-codex/src/shared/constants.ts @@ -4,7 +4,6 @@ export const CODEX_PROVIDER_ID = 'codex' export const CODEX_FAST_SERVICE_TIER = 'priority' export const CODEX_FLEX_SERVICE_TIER = 'flex' export const CODEX_DEFAULT_VERSION = '1.15.7' -export const DEFAULT_CHUNK_TIMEOUT_MS = 120_000 export const CODEX_FIRST_EVENT_TIMEOUT_MS = 60_000 export const CODEX_FIRST_EVENT_TIMEOUT_RETRIES = 5 export const CODEX_FIRST_EVENT_RETRY_BASE_DELAY_MS = 1_000 diff --git a/packages/agentlayer-provider-openai-codex/src/shared/sse.ts b/packages/agentlayer-provider-openai-codex/src/shared/sse.ts deleted file mode 100644 index ab948e4..0000000 --- a/packages/agentlayer-provider-openai-codex/src/shared/sse.ts +++ /dev/null @@ -1,63 +0,0 @@ -export function parseSseEvents(buffer: string): { events: string[]; remainder: string } { - const events: string[] = [] - let pos = 0 - while (true) { - const boundary = buffer.indexOf('\n\n', pos) - if (boundary === -1) break - const block = buffer.slice(pos, boundary) - pos = boundary + 2 - const dataLines: string[] = [] - for (const line of block.split('\n')) { - if (line.startsWith('data:')) dataLines.push(line.slice(5).trim()) - } - const data = dataLines.join('\n') - if (data && data !== '[DONE]') events.push(data) - } - return { events, remainder: buffer.slice(pos) } -} - -export function wrapSSE(res: Response, timeoutMs: number, abortCtl: AbortController, onTimeout?: () => void): Response { - if (typeof timeoutMs !== 'number' || timeoutMs <= 0) return res - if (!res.body) return res - if (!res.headers.get('content-type')?.includes('text/event-stream')) return res - - const reader = res.body.getReader() - const body = new ReadableStream({ - async pull(ctrl) { - const part = await new Promise<{ done: boolean; value?: Uint8Array }>((resolve, reject) => { - const id = setTimeout(() => { - const err = new Error(`SSE stream read timed out after ${timeoutMs}ms - no data received`) - onTimeout?.() - abortCtl.abort(err) - void reader.cancel(err) - reject(err) - }, timeoutMs) - reader.read().then( - (result) => { - clearTimeout(id) - resolve(result) - }, - (err) => { - clearTimeout(id) - reject(err) - }, - ) - }) - if (part.done) { - ctrl.close() - return - } - ctrl.enqueue(part.value) - }, - async cancel(reason) { - abortCtl.abort(reason) - await reader.cancel(reason) - }, - }) - - return new Response(body, { - headers: new Headers(res.headers), - status: res.status, - statusText: res.statusText, - }) -} diff --git a/packages/agentlayer-provider-openai-codex/src/shared/types.ts b/packages/agentlayer-provider-openai-codex/src/shared/types.ts index f5add9f..a129603 100644 --- a/packages/agentlayer-provider-openai-codex/src/shared/types.ts +++ b/packages/agentlayer-provider-openai-codex/src/shared/types.ts @@ -50,7 +50,7 @@ export interface CodexDiagnosticRecord { export type CodexDiagnosticSeverity = 'debug' | 'info' | 'warning' | 'error' -export type CodexDiagnosticTransport = 'sse' | 'websockets' | 'aisdk_responses' +export type CodexDiagnosticTransport = 'sse' | 'websockets' | 'custom_responses' /** * Opaque diagnostics context the host threads through CodeLayer into the Codex diff --git a/packages/agentlayer-provider-openai-codex/test/codex-auth.test.ts b/packages/agentlayer-provider-openai-codex/test/codex-auth.test.ts new file mode 100644 index 0000000..81806e3 --- /dev/null +++ b/packages/agentlayer-provider-openai-codex/test/codex-auth.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test' +import { createMemoryAuthStore } from '@humanlayer/agentlayer-provider-auth' +import { resolveCodexAuth } from '../src/shared/auth' +import { CODEX_PROVIDER_ID } from '../src/shared/constants' + +// Both live transports (sse-vendor and websockets-vendor) resolve auth through +// resolveCodexAuth before every request. The expired-token refresh-and-persist +// path previously had its only coverage in the deleted legacy provider suite; +// these tests pin it directly. +describe('resolveCodexAuth', () => { + test('refreshes an expired oauth token and persists the update', async () => { + const store = createMemoryAuthStore({ + [CODEX_PROVIDER_ID]: { + kind: 'oauth', + accessToken: 'expired-access', + refreshToken: 'refresh-123', + expiresAt: 1, + }, + }) + const seen: string[] = [] + const fetchFn = (async (input: string | URL | Request, init?: RequestInit) => { + seen.push(String(init?.body)) + return Response.json({ + access_token: 'fresh-access', + refresh_token: 'fresh-refresh', + expires_in: 3600, + }) + }) as unknown as typeof globalThis.fetch + + const auth = await resolveCodexAuth(store, CODEX_PROVIDER_ID, fetchFn, () => 10_000) + + expect(auth.kind).toBe('oauth') + expect((auth as { accessToken: string }).accessToken).toBe('fresh-access') + expect((auth as { expiresAt?: number }).expiresAt).toBe(10_000 + 3600 * 1000) + expect(seen.length).toBe(1) + // The refreshed token must be PERSISTED, or the next request refreshes again. + const stored = await store.get(CODEX_PROVIDER_ID) + expect((stored as { accessToken: string }).accessToken).toBe('fresh-access') + expect((stored as { refreshToken: string }).refreshToken).toBe('fresh-refresh') + }) + + test('returns an unexpired oauth token without refreshing', async () => { + const store = createMemoryAuthStore({ + [CODEX_PROVIDER_ID]: { + kind: 'oauth', + accessToken: 'still-good', + refreshToken: 'refresh-123', + expiresAt: 999_999, + }, + }) + const fetchFn = (async () => { + throw new Error('must not refresh') + }) as unknown as typeof globalThis.fetch + + const auth = await resolveCodexAuth(store, CODEX_PROVIDER_ID, fetchFn, () => 10_000) + expect((auth as { accessToken: string }).accessToken).toBe('still-good') + }) + + test('passes api-key auth through untouched', async () => { + const store = createMemoryAuthStore({ + [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'api-key-123' }, + }) + const fetchFn = (async () => { + throw new Error('must not fetch') + }) as unknown as typeof globalThis.fetch + + const auth = await resolveCodexAuth(store, CODEX_PROVIDER_ID, fetchFn, () => 0) + expect(auth.kind).toBe('api') + }) +}) diff --git a/packages/agentlayer-provider-openai-codex/test/codex-provider.test.ts b/packages/agentlayer-provider-openai-codex/test/codex-provider.test.ts deleted file mode 100644 index 1e41582..0000000 --- a/packages/agentlayer-provider-openai-codex/test/codex-provider.test.ts +++ /dev/null @@ -1,897 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import * as fs from 'node:fs/promises' -import { tmpdir } from 'node:os' -import path from 'node:path' -import { setTimeout as sleep } from 'node:timers/promises' -import { Agent, extractLastAssistantText, startState, userMessage } from '@humanlayer/agentlayer-core' -import { createMemoryAuthStore } from '@humanlayer/agentlayer-provider-auth' -import { generateText, jsonSchema, streamText } from 'ai' -import { - buildCodexHeaders, - buildCodexUserAgent, - CODEX_API_ENDPOINT, - CODEX_PROVIDER, - CODEX_PROVIDER_ID, - createCodexLanguageModel, - createCodexProvider, -} from '../src/legacy' - -function encodeSseEvents(events: unknown[], includeDone = true): string { - return `${events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join('')}${includeDone ? 'data: [DONE]\n\n' : ''}` -} - -function createSseResponse(events: unknown[]): Response { - return new Response(encodeSseEvents(events), { - status: 200, - headers: { 'content-type': 'text/event-stream' }, - }) -} - -const CODEX_REASONING_ONLY_REPRO_PROMPT = - 'can you think about how I coul ddesign a code mode to allow an agent to write and execute y.js code in a sandbox to execute commands to apply edits to a live y.js doc and then outline to me ways that we could approach the problem?' - -async function makeTempDir(): Promise { - return fs.mkdtemp(path.join(tmpdir(), 'agentlayer-codex-')) -} - -function createDeferredSseResponse(initialEvents: unknown[], trailingEvents: unknown[]) { - const encoder = new TextEncoder() - let releaseTrailing!: () => void - const trailingGate = new Promise((resolve) => { - releaseTrailing = resolve - }) - - const body = new ReadableStream({ - start(controller) { - controller.enqueue(encoder.encode(encodeSseEvents(initialEvents, false))) - void trailingGate.then(() => { - controller.enqueue(encoder.encode(encodeSseEvents(trailingEvents))) - controller.close() - }) - }, - }) - - return { - response: new Response(body, { - status: 200, - headers: { 'content-type': 'text/event-stream' }, - }), - releaseTrailing, - } -} - -describe('codex provider wrapper', () => { - test('createCodexProvider defaults to the disk-backed auth store', async () => { - const dir = await makeTempDir() - const filePath = path.join(dir, 'auth.json') - await fs.writeFile( - filePath, - JSON.stringify({ - codex: { - kind: 'oauth', - accessToken: 'disk-access', - accountId: 'acct_disk', - }, - }), - ) - const previousAuthPath = process.env.AGENTLAYER_AUTH_PATH - process.env.AGENTLAYER_AUTH_PATH = filePath - const calls: Array<{ url: string; init?: RequestInit }> = [] - - try { - const provider = createCodexProvider({ - version: '1.2.3', - fetch: async (input, init) => { - calls.push({ url: input instanceof URL ? input.toString() : String(input), init }) - return createSseResponse([ - { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'message', id: 'msg_disk' }, - }, - { type: 'response.output_text.delta', item_id: 'msg_disk', delta: 'Hello from disk' }, - { - type: 'response.output_item.done', - output_index: 0, - item: { type: 'message', id: 'msg_disk' }, - }, - { type: 'response.completed', response: { usage: { input_tokens: 1, output_tokens: 1 } } }, - ]) - }, - }) - - const result = await provider.languageModel('gpt-5.4').doGenerate({ - prompt: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }], - }) - - expect(calls).toHaveLength(1) - const headers = new Headers(calls[0]?.init?.headers) - expect(headers.get('authorization')).toBe('Bearer disk-access') - expect(headers.get('ChatGPT-Account-Id')).toBe('acct_disk') - expect(result.content).toEqual([ - { type: 'text', text: 'Hello from disk', providerMetadata: { openai: { itemId: 'msg_disk' } } }, - ]) - } finally { - if (previousAuthPath === undefined) { - delete process.env.AGENTLAYER_AUTH_PATH - } else { - process.env.AGENTLAYER_AUTH_PATH = previousAuthPath - } - } - }) - - test('createCodexProvider creates language models with OpenCode request behavior', async () => { - const store = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { - kind: 'oauth', - accessToken: 'oauth-access', - accountId: 'acct_123', - }, - }) - const calls: Array<{ url: string; init?: RequestInit }> = [] - const provider = createCodexProvider({ - authStore: store, - version: '1.2.3', - sessionId: 'session-abc', - fetch: async (input, init) => { - calls.push({ url: input instanceof URL ? input.toString() : String(input), init }) - return createSseResponse([ - { type: 'response.created', response: { id: 'resp_1', created_at: 1700000000, model: 'gpt-5.4' } }, - { type: 'response.output_item.added', output_index: 0, item: { type: 'message', id: 'msg_1' } }, - { type: 'response.output_text.delta', item_id: 'msg_1', delta: 'Hello from Codex' }, - { type: 'response.output_item.done', output_index: 0, item: { type: 'message', id: 'msg_1' } }, - { - type: 'response.completed', - response: { - usage: { - input_tokens: 10, - input_tokens_details: { cached_tokens: 2 }, - output_tokens: 4, - output_tokens_details: { reasoning_tokens: 1 }, - }, - }, - }, - ]) - }, - }) - const model = provider.languageModel('gpt-5.4') - - expect(model.specificationVersion).toBe('v3') - expect(model.provider).toBe(CODEX_PROVIDER) - expect(model.modelId).toBe('gpt-5.4') - - const result = await model.doGenerate({ - prompt: [ - { role: 'system', content: 'Be helpful.' }, - { role: 'user', content: [{ type: 'text', text: 'Say hi.' }] }, - ], - headers: { - authorization: 'Bearer caller-token', - 'x-extra': 'extra-header', - }, - }) - - expect(calls).toHaveLength(1) - expect(calls[0]?.url).toBe(CODEX_API_ENDPOINT) - const headers = new Headers(calls[0]?.init?.headers) - expect(headers.get('authorization')).toBe('Bearer oauth-access') - expect(headers.get('ChatGPT-Account-Id')).toBe('acct_123') - expect(headers.get('originator')).toBe('opencode') - expect(headers.get('session-id')).toBe('session-abc') - expect(headers.get('User-Agent')).toBe(buildCodexUserAgent('1.2.3')) - expect(headers.get('x-extra')).toBe('extra-header') - - expect(result.content).toEqual([ - { - type: 'text', - text: 'Hello from Codex', - providerMetadata: { openai: { itemId: 'msg_1', responseId: 'resp_1' } }, - }, - ]) - expect(result.finishReason).toEqual({ unified: 'stop', raw: undefined }) - expect(result.usage).toEqual({ - inputTokens: { total: 10, noCache: 8, cacheRead: 2, cacheWrite: undefined }, - outputTokens: { total: 4, text: 3, reasoning: 1 }, - }) - }) - - test('refreshes expired oauth auth before the request', async () => { - const store = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { - kind: 'oauth', - accessToken: 'expired-access', - refreshToken: 'refresh-123', - expiresAt: 1, - }, - }) - const seenBodies: string[] = [] - const model = createCodexLanguageModel({ - modelId: 'gpt-5.4', - authStore: store, - now: () => 10, - fetch: async (input, init) => { - const url = input instanceof URL ? input.toString() : String(input) - if (url.endsWith('/oauth/token')) { - seenBodies.push(String(init?.body)) - return Response.json({ access_token: 'fresh-access', refresh_token: 'refresh-456', expires_in: 60 }) - } - return createSseResponse([ - { type: 'response.completed', response: { usage: { input_tokens: 1, output_tokens: 1 } } }, - ]) - }, - }) - - await model.doGenerate({ prompt: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }] }) - - expect(seenBodies[0]).toContain('grant_type=refresh_token') - expect((await store.get(CODEX_PROVIDER_ID))?.kind).toBe('oauth') - const refreshed = await store.get(CODEX_PROVIDER_ID) - if (!refreshed || refreshed.kind !== 'oauth') throw new Error('expected oauth auth') - expect(refreshed.accessToken).toBe('fresh-access') - expect(refreshed.refreshToken).toBe('refresh-456') - }) - - test('reconstructs non-stream results from the Codex sse stream', async () => { - const store = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'api-key-123' }, - }) - const model = createCodexLanguageModel({ - modelId: 'gpt-5.4', - authStore: store, - fetch: async () => - createSseResponse([ - { type: 'response.created', response: { id: 'resp_2', created_at: 1700000001, model: 'gpt-5.4' } }, - { type: 'response.output_item.added', output_index: 0, item: { type: 'message', id: 'msg_2' } }, - { type: 'response.output_text.delta', item_id: 'msg_2', delta: 'Line 1' }, - { type: 'response.output_text.delta', item_id: 'msg_2', delta: ' and line 2' }, - { type: 'response.output_item.done', output_index: 0, item: { type: 'message', id: 'msg_2' } }, - { type: 'response.completed', response: { usage: { input_tokens: 2, output_tokens: 3 } } }, - ]), - }) - - const result = await model.doGenerate({ - prompt: [{ role: 'user', content: [{ type: 'text', text: 'Combine lines' }] }], - }) - expect(result.content).toEqual([ - { - type: 'text', - text: 'Line 1 and line 2', - providerMetadata: { openai: { itemId: 'msg_2', responseId: 'resp_2' } }, - }, - ]) - }) - - test('Agent run persists assistant item ids so follow-up requests replay them', async () => { - const store = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'api-key-123' }, - }) - const requestBodies: Array> = [] - const model = createCodexLanguageModel({ - modelId: 'gpt-5.4', - authStore: store, - fetch: async (_input, init) => { - requestBodies.push(JSON.parse(String(init?.body)) as Record) - if (requestBodies.length === 1) { - return createSseResponse([ - { - type: 'response.created', - response: { id: 'resp_first', created_at: 1700000001, model: 'gpt-5.4' }, - }, - { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'message', id: 'msg_first' }, - }, - { type: 'response.output_text.delta', item_id: 'msg_first', delta: 'First answer' }, - { - type: 'response.output_item.done', - output_index: 0, - item: { type: 'message', id: 'msg_first' }, - }, - { type: 'response.completed', response: { usage: { input_tokens: 2, output_tokens: 2 } } }, - ]) - } - - return createSseResponse([ - { - type: 'response.created', - response: { id: 'resp_second', created_at: 1700000002, model: 'gpt-5.4' }, - }, - { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'message', id: 'msg_second' }, - }, - { type: 'response.output_text.delta', item_id: 'msg_second', delta: 'Second answer' }, - { type: 'response.output_item.done', output_index: 0, item: { type: 'message', id: 'msg_second' } }, - { type: 'response.completed', response: { usage: { input_tokens: 3, output_tokens: 2 } } }, - ]) - }, - }) - - const agent = new Agent({ - model, - tools: {}, - providerOptions: { openai: { include: ['reasoning.encrypted_content'] } }, - }) - const first = await agent.run({ state: startState([userMessage('Hello')]), stream: false }).result - const second = await agent.run({ - state: startState([...first.state.messages, userMessage('Follow up')]), - stream: false, - }).result - - const assistantMessages = first.state.messages.filter((message) => message.role === 'assistant') - const persistedAssistant = assistantMessages.at(-1) as - | { content?: Array<{ providerOptions?: Record }> } - | undefined - - expect(requestBodies).toHaveLength(2) - expect(requestBodies[0]?.previous_response_id).toBeUndefined() - expect(requestBodies[1]?.previous_response_id).toBeUndefined() - expect(requestBodies[1]?.input).toEqual([ - { role: 'user', content: [{ type: 'input_text', text: 'Hello' }] }, - { role: 'assistant', content: [{ type: 'output_text', text: 'First answer' }] }, - { role: 'user', content: [{ type: 'input_text', text: 'Follow up' }] }, - ]) - expect(persistedAssistant?.content?.[0]?.providerOptions).toEqual({ - openai: { itemId: 'msg_first', responseId: 'resp_first' }, - }) - expect(second.state.messages.at(-1)).toMatchObject({ - role: 'assistant', - content: [ - { type: 'text', text: 'Second answer', providerOptions: { openai: { responseId: 'resp_second' } } }, - ], - }) - }) - - test('Agent continues after reasoning-only response by replaying reasoning input when store is false', async () => { - const store = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'api-key-123' }, - }) - const requestBodies: Array> = [] - const model = createCodexLanguageModel({ - modelId: 'gpt-5.4', - authStore: store, - fetch: async (_input, init) => { - requestBodies.push(JSON.parse(String(init?.body)) as Record) - if (requestBodies.length === 1) { - return createSseResponse([ - { - type: 'response.created', - response: { id: 'resp_reason_only', created_at: 1700000010, model: 'gpt-5.4' }, - }, - { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'reasoning', id: 'rs_only', encrypted_content: 'enc-only' }, - }, - { type: 'response.reasoning_summary_part.added', item_id: 'rs_only', summary_index: 0 }, - { - type: 'response.reasoning_summary_text.delta', - item_id: 'rs_only', - summary_index: 0, - delta: 'Thought.', - }, - { - type: 'response.output_item.done', - output_index: 0, - item: { type: 'reasoning', id: 'rs_only', encrypted_content: 'enc-only' }, - }, - { - type: 'response.completed', - response: { - usage: { - input_tokens: 2, - output_tokens: 3, - output_tokens_details: { reasoning_tokens: 3 }, - }, - }, - }, - ]) - } - - return createSseResponse([ - { - type: 'response.created', - response: { id: 'resp_final', created_at: 1700000011, model: 'gpt-5.4' }, - }, - { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'message', id: 'msg_final', phase: 'final_answer' }, - }, - { type: 'response.output_text.delta', item_id: 'msg_final', delta: 'Final answer.' }, - { - type: 'response.output_item.done', - output_index: 0, - item: { type: 'message', id: 'msg_final', phase: 'final_answer' }, - }, - { type: 'response.completed', response: { usage: { input_tokens: 5, output_tokens: 2 } } }, - ]) - }, - }) - - const agent = new Agent({ - model, - tools: {}, - providerOptions: { - openai: { store: false, reasoningSummary: 'auto', include: ['reasoning.encrypted_content'] }, - }, - }) - const result = await agent.run({ - state: startState([userMessage(CODEX_REASONING_ONLY_REPRO_PROMPT)]), - stream: false, - }).result - - expect(result.finishReason).toBe('complete') - expect(extractLastAssistantText(result)).toBe('Final answer.') - expect(requestBodies).toHaveLength(2) - expect(requestBodies[1]?.input).toEqual([ - { role: 'user', content: [{ type: 'input_text', text: CODEX_REASONING_ONLY_REPRO_PROMPT }] }, - { - type: 'reasoning', - encrypted_content: 'enc-only', - summary: [{ type: 'summary_text', text: 'Thought.' }], - }, - ]) - }) - - test('streams Codex SSE parts incrementally instead of buffering the full response', async () => { - const store = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'api-key-123' }, - }) - const { response, releaseTrailing } = createDeferredSseResponse( - [ - { type: 'response.created', response: { id: 'resp_stream', created_at: 1700000002, model: 'gpt-5.4' } }, - { type: 'response.output_item.added', output_index: 0, item: { type: 'message', id: 'msg_stream' } }, - { type: 'response.output_text.delta', item_id: 'msg_stream', delta: 'Hello' }, - ], - [ - { type: 'response.output_text.delta', item_id: 'msg_stream', delta: ' world' }, - { type: 'response.output_item.done', output_index: 0, item: { type: 'message', id: 'msg_stream' } }, - { type: 'response.completed', response: { usage: { input_tokens: 2, output_tokens: 2 } } }, - ], - ) - const model = createCodexLanguageModel({ - modelId: 'gpt-5.4', - authStore: store, - fetch: async () => response, - }) - - const result = await model.doStream({ - prompt: [{ role: 'user', content: [{ type: 'text', text: 'Say hello' }] }], - }) - const reader = result.stream.getReader() - - expect(await reader.read()).toEqual({ done: false, value: { type: 'stream-start', warnings: [] } }) - expect(await reader.read()).toEqual({ - done: false, - value: { - type: 'response-metadata', - id: 'resp_stream', - timestamp: new Date(1700000002 * 1000), - modelId: 'gpt-5.4', - }, - }) - expect(await reader.read()).toEqual({ - done: false, - value: { - type: 'text-start', - id: 'msg_stream', - providerMetadata: { openai: { itemId: 'msg_stream', responseId: 'resp_stream' } }, - }, - }) - expect(await reader.read()).toEqual({ - done: false, - value: { type: 'text-delta', id: 'msg_stream', delta: 'Hello' }, - }) - - let pendingResolved = false - const pendingRead = reader.read().then((value) => { - pendingResolved = true - return value - }) - await sleep(20) - expect(pendingResolved).toBe(false) - - releaseTrailing() - - expect(await pendingRead).toEqual({ - done: false, - value: { type: 'text-delta', id: 'msg_stream', delta: ' world' }, - }) - expect(await reader.read()).toEqual({ - done: false, - value: { - type: 'text-end', - id: 'msg_stream', - providerMetadata: { openai: { itemId: 'msg_stream', responseId: 'resp_stream' } }, - }, - }) - expect(await reader.read()).toEqual({ - done: false, - value: { - type: 'finish', - finishReason: { unified: 'stop', raw: undefined }, - usage: { - inputTokens: { total: 2, noCache: 2, cacheRead: undefined, cacheWrite: undefined }, - outputTokens: { total: 2, text: 2, reasoning: undefined }, - }, - providerMetadata: { openai: { responseId: 'resp_stream' } }, - }, - }) - expect(await reader.read()).toEqual({ done: true, value: undefined }) - expect(result.response).toEqual({ headers: { 'content-type': 'text/event-stream' } }) - }) - - test('streamText fullStream emits Codex function calls and arguments', async () => { - const store = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'api-key-123' }, - }) - const model = createCodexLanguageModel({ - modelId: 'gpt-5.4', - authStore: store, - fetch: async () => - createSseResponse([ - { - type: 'response.created', - response: { id: 'resp_tool', created_at: 1700000014, model: 'gpt-5.4' }, - }, - { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'function_call', id: 'fc_1', call_id: 'call_1', name: 'read', arguments: '' }, - }, - { - type: 'response.function_call_arguments.delta', - output_index: 0, - item_id: 'fc_1', - delta: '{"filePath"', - }, - { - type: 'response.function_call_arguments.delta', - output_index: 0, - item_id: 'fc_1', - delta: ':"README.md"}', - }, - { - type: 'response.function_call_arguments.done', - output_index: 0, - item_id: 'fc_1', - arguments: '{"filePath":"README.md"}', - }, - { - type: 'response.output_item.done', - output_index: 0, - item: { - type: 'function_call', - id: 'fc_1', - call_id: 'call_1', - name: 'read', - arguments: '{"filePath":"README.md"}', - }, - }, - { type: 'response.completed', response: { usage: { input_tokens: 2, output_tokens: 4 } } }, - ]), - }) - - const result = streamText({ - model, - prompt: 'Read a file.', - tools: { - read: { - description: 'Read a file', - inputSchema: jsonSchema({ - type: 'object', - properties: { filePath: { type: 'string' } }, - required: ['filePath'], - }), - }, - }, - }) - const parts = [] as Array<{ type: string; [key: string]: unknown }> - for await (const part of result.fullStream) { - parts.push(part as { type: string; [key: string]: unknown }) - } - - expect(parts.map((part) => part.type)).toContain('tool-input-start') - expect(parts.map((part) => part.type)).toContain('tool-input-delta') - expect(parts.map((part) => part.type)).toContain('tool-call') - expect(await result.toolCalls).toMatchObject([ - { type: 'tool-call', toolCallId: 'call_1', toolName: 'read', input: { filePath: 'README.md' } }, - ]) - }) - - test('buildCodexHeaders strips caller authorization headers', () => { - const headers = buildCodexHeaders({ - auth: { kind: 'api', apiKey: 'server-key' }, - version: '2.0.0', - callerHeaders: { - authorization: 'Bearer caller-key', - Authorization: 'Bearer other', - 'x-test': 'ok', - }, - }) - - expect(headers.authorization).toBe('Bearer server-key') - expect(headers['x-test']).toBe('ok') - expect(headers['user-agent']).toBe(buildCodexUserAgent('2.0.0')) - }) - - test('generateText reconstructs reasoning summaries for non-stream callers', async () => { - const store = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'api-key-123' }, - }) - const model = createCodexLanguageModel({ - modelId: 'gpt-5.4', - authStore: store, - fetch: async () => - createSseResponse([ - { - type: 'response.created', - response: { id: 'resp_reason', created_at: 1700000003, model: 'gpt-5.4' }, - }, - { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'reasoning', id: 'rs_1', encrypted_content: 'enc-final' }, - }, - { type: 'response.reasoning_summary_part.added', item_id: 'rs_1', summary_index: 0 }, - { - type: 'response.reasoning_summary_text.delta', - item_id: 'rs_1', - summary_index: 0, - delta: 'First thought.', - }, - { type: 'response.reasoning_summary_part.done', item_id: 'rs_1', summary_index: 0 }, - { type: 'response.reasoning_summary_part.added', item_id: 'rs_1', summary_index: 1 }, - { - type: 'response.reasoning_summary_text.delta', - item_id: 'rs_1', - summary_index: 1, - delta: 'Second thought.', - }, - { - type: 'response.output_item.done', - output_index: 0, - item: { type: 'reasoning', id: 'rs_1', encrypted_content: 'enc-final' }, - }, - { - type: 'response.output_item.added', - output_index: 1, - item: { type: 'message', id: 'msg_3', phase: 'final_answer' }, - }, - { type: 'response.output_text.delta', item_id: 'msg_3', delta: 'Answer.' }, - { - type: 'response.output_item.done', - output_index: 1, - item: { type: 'message', id: 'msg_3', phase: 'final_answer' }, - }, - { type: 'response.completed', response: { usage: { input_tokens: 3, output_tokens: 5 } } }, - ]), - }) - - const result = await generateText({ - model, - prompt: 'Think through this.', - providerOptions: { - openai: { - store: false, - reasoningSummary: 'auto', - include: ['reasoning.encrypted_content'], - }, - }, - }) - - expect(result.reasoning).toEqual([ - { - type: 'reasoning', - text: 'First thought.', - providerMetadata: { - openai: { itemId: 'rs_1', reasoningEncryptedContent: 'enc-final', responseId: 'resp_reason' }, - }, - }, - { - type: 'reasoning', - text: 'Second thought.', - providerMetadata: { - openai: { itemId: 'rs_1', reasoningEncryptedContent: 'enc-final', responseId: 'resp_reason' }, - }, - }, - ]) - expect(result.reasoningText).toBe('First thought.Second thought.') - expect(result.text).toBe('Answer.') - expect(result.providerMetadata).toEqual({ openai: { responseId: 'resp_reason' } }) - }) - - test('streamText fullStream emits reasoning events before final text', async () => { - const store = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'api-key-123' }, - }) - const model = createCodexLanguageModel({ - modelId: 'gpt-5.4', - authStore: store, - fetch: async () => - createSseResponse([ - { - type: 'response.created', - response: { id: 'resp_stream_reason', created_at: 1700000004, model: 'gpt-5.4' }, - }, - { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'reasoning', id: 'rs_stream', encrypted_content: 'enc-stream' }, - }, - { type: 'response.reasoning_summary_part.added', item_id: 'rs_stream', summary_index: 0 }, - { - type: 'response.reasoning_summary_text.delta', - item_id: 'rs_stream', - summary_index: 0, - delta: 'Think aloud.', - }, - { - type: 'response.output_item.done', - output_index: 0, - item: { type: 'reasoning', id: 'rs_stream', encrypted_content: 'enc-stream' }, - }, - { - type: 'response.output_item.added', - output_index: 1, - item: { type: 'message', id: 'msg_stream_reason', phase: 'commentary' }, - }, - { type: 'response.output_text.delta', item_id: 'msg_stream_reason', delta: 'Final answer.' }, - { - type: 'response.output_item.done', - output_index: 1, - item: { type: 'message', id: 'msg_stream_reason', phase: 'commentary' }, - }, - { type: 'response.completed', response: { usage: { input_tokens: 2, output_tokens: 4 } } }, - ]), - }) - - const result = streamText({ - model, - prompt: 'Explain it.', - providerOptions: { - openai: { - store: false, - reasoningSummary: 'auto', - include: ['reasoning.encrypted_content'], - }, - }, - }) - const parts = [] as Array<{ type: string; [key: string]: unknown }> - for await (const part of result.fullStream) { - parts.push(part as { type: string; [key: string]: unknown }) - } - - expect(parts.map((part) => part.type)).toEqual([ - 'start', - 'start-step', - 'reasoning-start', - 'reasoning-delta', - 'reasoning-end', - 'text-start', - 'text-delta', - 'text-end', - 'finish-step', - 'finish', - ]) - const reasoningStart = parts.find((part) => part.type === 'reasoning-start') - const reasoningDelta = parts.find((part) => part.type === 'reasoning-delta') - const reasoningEnd = parts.find((part) => part.type === 'reasoning-end') - const textDelta = parts.find((part) => part.type === 'text-delta') - const textStartIndex = parts.findIndex((part) => part.type === 'text-start') - const reasoningEndIndex = parts.findIndex((part) => part.type === 'reasoning-end') - - expect(reasoningStart).toMatchObject({ - type: 'reasoning-start', - id: 'rs_stream:0', - providerMetadata: { openai: { itemId: 'rs_stream', reasoningEncryptedContent: 'enc-stream' } }, - }) - expect(reasoningDelta).toMatchObject({ - type: 'reasoning-delta', - id: 'rs_stream:0', - text: 'Think aloud.', - }) - expect(reasoningEnd).toMatchObject({ - type: 'reasoning-end', - id: 'rs_stream:0', - providerMetadata: { openai: { itemId: 'rs_stream', reasoningEncryptedContent: 'enc-stream' } }, - }) - expect(textDelta).toMatchObject({ type: 'text-delta', text: 'Final answer.' }) - expect(reasoningEndIndex).toBeLessThan(textStartIndex) - expect(await result.reasoningText).toBe('Think aloud.') - expect(await result.text).toBe('Final answer.') - }) - - test('streamText response messages preserve encrypted reasoning metadata for agent state', async () => { - const store = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'api-key-123' }, - }) - const model = createCodexLanguageModel({ - modelId: 'gpt-5.4', - authStore: store, - fetch: async () => - createSseResponse([ - { - type: 'response.created', - response: { id: 'resp_state_reason', created_at: 1700000005, model: 'gpt-5.4' }, - }, - { - type: 'response.output_item.added', - output_index: 0, - item: { type: 'reasoning', id: 'rs_state', encrypted_content: 'enc-state' }, - }, - { type: 'response.reasoning_summary_part.added', item_id: 'rs_state', summary_index: 0 }, - { - type: 'response.reasoning_summary_text.delta', - item_id: 'rs_state', - summary_index: 0, - delta: 'Stored thought.', - }, - { - type: 'response.output_item.done', - output_index: 0, - item: { type: 'reasoning', id: 'rs_state', encrypted_content: 'enc-state' }, - }, - { - type: 'response.output_item.added', - output_index: 1, - item: { type: 'message', id: 'msg_state', phase: 'final_answer' }, - }, - { type: 'response.output_text.delta', item_id: 'msg_state', delta: 'Saved answer.' }, - { - type: 'response.output_item.done', - output_index: 1, - item: { type: 'message', id: 'msg_state', phase: 'final_answer' }, - }, - { type: 'response.completed', response: { usage: { input_tokens: 2, output_tokens: 4 } } }, - ]), - }) - - const result = streamText({ - model, - prompt: 'Persist this.', - providerOptions: { - openai: { - store: false, - reasoningSummary: 'auto', - include: ['reasoning.encrypted_content'], - }, - }, - }) - - const response = await result.response - const providerMetadata = await result.providerMetadata - expect(response.messages).toHaveLength(1) - expect(response.messages[0]).toMatchObject({ - role: 'assistant', - content: [ - { - type: 'reasoning', - text: 'Stored thought.', - providerOptions: { - openai: { - itemId: 'rs_state', - reasoningEncryptedContent: 'enc-state', - responseId: 'resp_state_reason', - }, - }, - }, - { - type: 'text', - text: 'Saved answer.', - providerOptions: { - openai: { - itemId: 'msg_state', - phase: 'final_answer', - responseId: 'resp_state_reason', - }, - }, - }, - ], - }) - expect(providerMetadata).toEqual({ openai: { responseId: 'resp_state_reason' } }) - }) -}) diff --git a/packages/agentlayer-provider-openai-codex/test/codex-responses-provider.test.ts b/packages/agentlayer-provider-openai-codex/test/codex-responses-provider.test.ts deleted file mode 100644 index cdc66bd..0000000 --- a/packages/agentlayer-provider-openai-codex/test/codex-responses-provider.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { describe, expect, it, mock } from 'bun:test' -import { createMemoryAuthStore } from '@humanlayer/agentlayer-provider-auth' -import { CODEX_API_ENDPOINT, CODEX_PROVIDER_ID, createCodexResponsesProvider } from '../src' - -describe('createCodexResponsesProvider', () => { - it('returns a ProviderV3 with languageModel method', () => { - const authStore = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'test-key' }, - }) - const provider = createCodexResponsesProvider({ authStore }) - - expect(provider.specificationVersion).toBe('v3') - expect(typeof provider.languageModel).toBe('function') - }) - - it('languageModel returns a model from upstream @ai-sdk/openai', () => { - const authStore = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'test-key' }, - }) - const provider = createCodexResponsesProvider({ authStore }) - - const model = provider.languageModel('gpt-5.5') - - expect(model).toBeDefined() - expect(model.modelId).toBe('gpt-5.5') - }) - - it('languageModel reports a codex-prefixed provider for registry discovery', () => { - const authStore = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'test-key' }, - }) - const provider = createCodexResponsesProvider({ authStore }) - - const model = provider.languageModel('gpt-5.5') - - expect(model.provider).toBe('codex.responses') - }) - - describe('custom fetch wrapper', () => { - it('rewrites URL to CODEX_API_ENDPOINT for /v1/responses', async () => { - const capturedRequests: { url: string; init?: RequestInit }[] = [] - const mockFetch = mock(async (url: string | URL | Request, init?: RequestInit) => { - capturedRequests.push({ url: url.toString(), init }) - return new Response(JSON.stringify({ error: 'test' }), { status: 400 }) - }) - - const authStore = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'test-key' }, - }) - const provider = createCodexResponsesProvider({ authStore, fetch: mockFetch }) - const model = provider.languageModel('gpt-5.5') - - try { - await model.doGenerate({ - prompt: [{ role: 'user', content: [{ type: 'text', text: 'test' }] }], - }) - } catch { - // Expected to fail - } - - expect(capturedRequests.length).toBeGreaterThan(0) - expect(capturedRequests[0]!.url).toBe(CODEX_API_ENDPOINT) - }) - - it('sets authorization header with Bearer token for API auth', async () => { - const capturedRequests: { url: string; init?: RequestInit }[] = [] - const mockFetch = mock(async (url: string | URL | Request, init?: RequestInit) => { - capturedRequests.push({ url: url.toString(), init }) - return new Response(JSON.stringify({ error: 'test' }), { status: 400 }) - }) - - const authStore = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'my-api-key' }, - }) - const provider = createCodexResponsesProvider({ authStore, fetch: mockFetch }) - const model = provider.languageModel('gpt-5.5') - - try { - await model.doGenerate({ - prompt: [{ role: 'user', content: [{ type: 'text', text: 'test' }] }], - }) - } catch { - // Expected to fail - } - - const headers = new Headers(capturedRequests[0]!.init?.headers) - expect(headers.get('authorization')).toBe('Bearer my-api-key') - }) - - it('sets ChatGPT-Account-Id header for OAuth auth with accountId', async () => { - const capturedRequests: { url: string; init?: RequestInit }[] = [] - const mockFetch = mock(async (url: string | URL | Request, init?: RequestInit) => { - capturedRequests.push({ url: url.toString(), init }) - return new Response(JSON.stringify({ error: 'test' }), { status: 400 }) - }) - - const authStore = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { - kind: 'oauth', - accessToken: 'oauth-token', - accountId: 'account-123', - }, - }) - const provider = createCodexResponsesProvider({ authStore, fetch: mockFetch }) - const model = provider.languageModel('gpt-5.5') - - try { - await model.doGenerate({ - prompt: [{ role: 'user', content: [{ type: 'text', text: 'test' }] }], - }) - } catch { - // Expected to fail - } - - const headers = new Headers(capturedRequests[0]!.init?.headers) - expect(headers.get('ChatGPT-Account-Id')).toBe('account-123') - }) - - it('forces store=false and include defaults in request body', async () => { - const capturedRequests: { url: string; init?: RequestInit }[] = [] - const mockFetch = mock(async (url: string | URL | Request, init?: RequestInit) => { - capturedRequests.push({ url: url.toString(), init }) - return new Response(JSON.stringify({ error: 'test' }), { status: 400 }) - }) - - const authStore = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'test-key' }, - }) - const provider = createCodexResponsesProvider({ authStore, fetch: mockFetch }) - const model = provider.languageModel('gpt-5.5') - - try { - await model.doGenerate({ - prompt: [{ role: 'user', content: [{ type: 'text', text: 'test' }] }], - }) - } catch { - // Expected to fail - } - - const body = JSON.parse(capturedRequests[0]!.init?.body as string) - expect(body.store).toBe(false) - expect(body.include).toEqual(['reasoning.encrypted_content']) - }) - - it('strips id fields from input items', async () => { - const capturedRequests: { url: string; init?: RequestInit }[] = [] - const mockFetch = mock(async (url: string | URL | Request, init?: RequestInit) => { - capturedRequests.push({ url: url.toString(), init }) - return new Response(JSON.stringify({ error: 'test' }), { status: 400 }) - }) - - const authStore = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'test-key' }, - }) - const provider = createCodexResponsesProvider({ authStore, fetch: mockFetch }) - const model = provider.languageModel('gpt-5.5') - - try { - await model.doGenerate({ - prompt: [{ role: 'user', content: [{ type: 'text', text: 'test' }] }], - }) - } catch { - // Expected to fail - } - - const body = JSON.parse(capturedRequests[0]!.init?.body as string) - for (const item of body.input) { - expect(item.id).toBeUndefined() - } - }) - - it('applies fastMode as service_tier=priority', async () => { - const capturedRequests: { url: string; init?: RequestInit }[] = [] - const mockFetch = mock(async (url: string | URL | Request, init?: RequestInit) => { - capturedRequests.push({ url: url.toString(), init }) - return new Response(JSON.stringify({ error: 'test' }), { status: 400 }) - }) - - const authStore = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { kind: 'api', apiKey: 'test-key' }, - }) - const provider = createCodexResponsesProvider({ authStore, fetch: mockFetch, fastMode: true }) - const model = provider.languageModel('gpt-5.5') - - try { - await model.doGenerate({ - prompt: [{ role: 'user', content: [{ type: 'text', text: 'test' }] }], - }) - } catch { - // Expected to fail - } - - const body = JSON.parse(capturedRequests[0]!.init?.body as string) - expect(body.service_tier).toBe('priority') - }) - }) -}) diff --git a/packages/agentlayer-provider-openai-codex/test/codex-transform.test.ts b/packages/agentlayer-provider-openai-codex/test/codex-transform.test.ts deleted file mode 100644 index 837a4ad..0000000 --- a/packages/agentlayer-provider-openai-codex/test/codex-transform.test.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { buildCodexRequestBody } from '../src/legacy' -import { normalizeCodexServiceTier } from '../src/shared/service-tier' - -describe('buildCodexRequestBody', () => { - test('moves system and developer instructions into top-level instructions', () => { - const body = buildCodexRequestBody( - { - prompt: [ - { role: 'system', content: 'Follow the repository rules.' }, - { - role: 'user', - content: [{ type: 'text', text: 'Write a function.' }], - }, - ], - providerOptions: { - openai: { - instructions: 'Prefer concise output.', - include: ['reasoning.encrypted_content'], - reasoningSummary: 'auto', - reasoningEffort: 'medium', - parallelToolCalls: false, - conversation: 'conv_123', - previousResponseId: 'resp_prev', - maxToolCalls: 2, - promptCacheKey: 'cache-key', - promptCacheRetention: '24h', - serviceTier: 'priority', - truncation: 'auto', - user: 'user_123', - metadata: { source: 'test' }, - max_output_tokens: 123, - }, - }, - }, - 'gpt-5.4', - ) - - expect(body.instructions).toBe('Follow the repository rules.\n\nPrefer concise output.') - expect(body.store).toBe(false) - expect(body.stream).toBe(true) - expect(body.include).toEqual(['reasoning.encrypted_content']) - expect(body.reasoning).toEqual({ effort: 'medium', summary: 'auto' }) - expect(body.parallel_tool_calls).toBe(false) - expect(body.conversation).toBe('conv_123') - expect(body.previous_response_id).toBeUndefined() - expect(body.max_tool_calls).toBe(2) - expect(body.prompt_cache_key).toBe('cache-key') - expect(body.prompt_cache_retention).toBe('24h') - expect(body.service_tier).toBe('priority') - expect(body.truncation).toBe('auto') - expect(body.user).toBe('user_123') - expect(body.metadata).toEqual({ source: 'test' }) - expect(body).not.toHaveProperty('max_output_tokens') - }) - - test('ignores store overrides because the Codex endpoint requires store false', () => { - const body = buildCodexRequestBody( - { - prompt: [{ role: 'user', content: [{ type: 'text', text: 'Think then answer.' }] }], - providerOptions: { - openai: { - store: true, - }, - }, - }, - 'gpt-5.4', - ) - - expect(body.store).toBe(false) - }) - - test('enables Codex fast mode from provider options', () => { - const body = buildCodexRequestBody( - { - prompt: [{ role: 'user', content: [{ type: 'text', text: 'Use fast mode' }] }], - providerOptions: { - codex: { - fastMode: true, - }, - }, - }, - 'gpt-5.4', - ) - - expect(body.service_tier).toBe('priority') - }) - - test('enables Codex fast mode from provider defaults', () => { - const body = buildCodexRequestBody( - { - prompt: [{ role: 'user', content: [{ type: 'text', text: 'Use fast mode' }] }], - }, - 'gpt-5.4', - { fastMode: true }, - ) - - expect(body.service_tier).toBe('priority') - }) - - test('normalizes fast service tier alias to Codex priority service tier', () => { - expect(normalizeCodexServiceTier('fast')).toBe('priority') - expect(normalizeCodexServiceTier('priority')).toBe('priority') - expect(normalizeCodexServiceTier('flex')).toBe('flex') - expect(normalizeCodexServiceTier(null)).toBeNull() - expect(normalizeCodexServiceTier(undefined)).toBeUndefined() - }) - - test('explicit service tier takes precedence over fast mode', () => { - const body = buildCodexRequestBody( - { - prompt: [{ role: 'user', content: [{ type: 'text', text: 'Use flex' }] }], - providerOptions: { - openai: { - fastMode: true, - serviceTier: 'flex', - }, - }, - }, - 'gpt-5.4', - { fastMode: true }, - ) - - expect(body.service_tier).toBe('flex') - }) - - test('serializes function tools for Codex requests', () => { - const body = buildCodexRequestBody( - { - prompt: [{ role: 'user', content: [{ type: 'text', text: 'Patch a file.' }] }], - tools: [ - { - type: 'function', - name: 'apply_patch', - description: 'Apply a patch to files.', - inputSchema: { - type: 'object', - properties: { patch_text: { type: 'string' } }, - required: ['patch_text'], - }, - }, - ], - }, - 'gpt-5.4', - ) - - expect(body.tools).toEqual([ - { - type: 'function', - name: 'apply_patch', - description: 'Apply a patch to files.', - parameters: { - type: 'object', - properties: { patch_text: { type: 'string' } }, - required: ['patch_text'], - }, - strict: false, - }, - ]) - }) - - test('strips item ids from assistant text when building request body', () => { - const body = buildCodexRequestBody( - { - prompt: [ - { - role: 'assistant', - content: [ - { - type: 'text', - text: 'Stored content', - providerOptions: { openai: { itemId: 'msg_123' } }, - }, - ], - }, - ], - }, - 'gpt-5.4', - ) - - expect(body.input).toEqual([{ role: 'assistant', content: [{ type: 'output_text', text: 'Stored content' }] }]) - }) - - test('serializes tool call outputs for tool messages', () => { - const body = buildCodexRequestBody( - { - prompt: [ - { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: 'call_123', - toolName: 'search', - output: { type: 'json', value: { ok: true } }, - }, - ], - }, - ], - }, - 'gpt-5.4', - ) - - expect(body.input).toEqual([ - { type: 'function_call_output', call_id: 'call_123', output: JSON.stringify({ ok: true }) }, - ]) - }) - - test('serializes multimodal tool outputs for Codex requests', () => { - const body = buildCodexRequestBody( - { - prompt: [ - { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: 'call_image', - toolName: 'read', - output: { - type: 'content', - value: [ - { type: 'text', text: 'Read image.png' }, - { type: 'image-data', data: 'iVBORw0KGgo=', mediaType: 'image/png' }, - { - type: 'file-data', - data: 'JVBERi0=', - mediaType: 'application/pdf', - filename: 'doc.pdf', - }, - ], - }, - }, - ], - }, - ], - }, - 'gpt-5.4', - ) - - expect(body.input).toEqual([ - { - type: 'function_call_output', - call_id: 'call_image', - output: [ - { type: 'input_text', text: 'Read image.png' }, - { type: 'input_image', image_url: 'data:image/png;base64,iVBORw0KGgo=' }, - { type: 'input_file', filename: 'doc.pdf', file_data: 'data:application/pdf;base64,JVBERi0=' }, - ], - }, - ]) - }) - - test('serializes assistant reasoning with item ids and encrypted content', () => { - const body = buildCodexRequestBody( - { - prompt: [ - { - role: 'assistant', - content: [ - { - type: 'reasoning', - text: 'Think first', - providerOptions: { - openai: { - itemId: 'rs_123', - reasoningEncryptedContent: 'enc_123', - }, - }, - }, - ], - }, - ], - }, - 'gpt-5.4', - ) - - expect(body.input).toEqual([ - { - type: 'reasoning', - encrypted_content: 'enc_123', - summary: [{ type: 'summary_text', text: 'Think first' }], - }, - ]) - }) - - test('serializes assistant reasoning from provider metadata for persisted follow-up turns', () => { - const body = buildCodexRequestBody( - { - prompt: [ - { - role: 'assistant', - content: [ - { - type: 'reasoning', - text: 'Persisted thought', - providerOptions: { - openai: { - itemId: 'rs_persisted', - reasoningEncryptedContent: 'enc_persisted', - }, - }, - }, - ], - }, - ], - }, - 'gpt-5.4', - ) - - expect(body.input).toEqual([ - { - type: 'reasoning', - encrypted_content: 'enc_persisted', - summary: [{ type: 'summary_text', text: 'Persisted thought' }], - }, - ]) - }) - - test('replays assistant text with its item id', () => { - const body = buildCodexRequestBody( - { - prompt: [ - { - role: 'assistant', - content: [ - { - type: 'text', - text: 'Earlier answer', - providerOptions: { - openai: { - itemId: 'msg_from_history', - }, - }, - }, - ], - }, - { - role: 'user', - content: [{ type: 'text', text: 'Follow up' }], - }, - ], - }, - 'gpt-5.4', - ) - - expect(body.input).toEqual([ - { role: 'assistant', content: [{ type: 'output_text', text: 'Earlier answer' }] }, - { role: 'user', content: [{ type: 'input_text', text: 'Follow up' }] }, - ]) - }) - - test('does not send explicit previousResponseId to the Codex endpoint', () => { - const body = buildCodexRequestBody( - { - prompt: [ - { - role: 'assistant', - content: [{ type: 'text', text: 'Earlier answer' }], - providerOptions: { - openai: { - responseId: 'resp_from_history', - }, - }, - }, - ], - providerOptions: { - openai: { - previousResponseId: 'resp_explicit', - }, - }, - }, - 'gpt-5.4', - ) - - expect(body.previous_response_id).toBeUndefined() - }) - - test('replays function calls with their item id', () => { - const body = buildCodexRequestBody( - { - prompt: [ - { - role: 'assistant', - content: [ - { - type: 'tool-call', - toolCallId: 'call_123', - toolName: 'search', - input: { query: 'test' }, - providerOptions: { - openai: { - itemId: 'fc_123', - }, - }, - }, - ], - }, - ], - }, - 'gpt-5.4', - ) - - expect(body.input).toEqual([ - { - type: 'function_call', - call_id: 'call_123', - name: 'search', - arguments: JSON.stringify({ query: 'test' }), - }, - ]) - }) - - test('passes promptCacheKey as prompt_cache_key in the request body', () => { - const body = buildCodexRequestBody( - { - prompt: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], - providerOptions: { - openai: { promptCacheKey: 'session-abc-123' }, - }, - }, - 'gpt-5.5', - ) - - expect(body.prompt_cache_key).toBe('session-abc-123') - }) - - test('defaults include to reasoning.encrypted_content when not provided', () => { - const body = buildCodexRequestBody( - { - prompt: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], - }, - 'gpt-5.5', - ) - - expect(body.include).toEqual(['reasoning.encrypted_content']) - }) - - test('strips id fields from all input item types', () => { - const body = buildCodexRequestBody( - { - prompt: [ - { - role: 'assistant', - content: [ - { - type: 'reasoning', - text: 'thinking...', - providerOptions: { - openai: { itemId: 'rs_123', reasoningEncryptedContent: 'enc' }, - }, - }, - { - type: 'text', - text: 'answer', - providerOptions: { openai: { itemId: 'msg_456' } }, - }, - { - type: 'tool-call', - toolCallId: 'call_789', - toolName: 'search', - input: { q: 'test' }, - providerOptions: { openai: { itemId: 'fc_789' } }, - }, - ], - }, - ], - }, - 'gpt-5.5', - ) - - for (const item of body.input) { - expect(item).not.toHaveProperty('id') - } - expect(body.input).toHaveLength(3) - }) -}) diff --git a/packages/agentlayer-provider-openai-codex/test/reasoning-continuation.learning.test.ts b/packages/agentlayer-provider-openai-codex/test/reasoning-continuation.learning.test.ts deleted file mode 100644 index 8330019..0000000 --- a/packages/agentlayer-provider-openai-codex/test/reasoning-continuation.learning.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * Learning test: Codex reasoning continuation with/without `id` field - * - * This test makes REAL API calls to Codex to verify: - * 1. Initial request returns reasoning with `itemId` and `encrypted_content` - * 2. Continuation WITHOUT `id` field fails (empty response) - * 3. Continuation WITH `id` field succeeds - * - * Run with: bun test reasoning-continuation.learning.test.ts - * Requires valid Codex auth in ~/.humanlayer/agent-sdk/auth.json - * - * Skipped in CI (no auth credentials available) - */ -import { describe, expect, setDefaultTimeout, test } from 'bun:test' - -setDefaultTimeout(60_000) // 60 second timeout for real API calls - -import { createFileAuthStore } from '@humanlayer/agentlayer-provider-auth' -import { streamText } from 'ai' -import { buildCodexRequestBody, createCodexLanguageModel } from '../src/legacy' - -const authStore = createFileAuthStore() -const hasAuth = await authStore - .get('codex') - .then((auth) => !!auth) - .catch(() => false) - -describe.skipIf(!hasAuth)('reasoning continuation learning test', () => { - test('captures reasoning metadata from initial response', async () => { - const model = createCodexLanguageModel({ - modelId: 'gpt-5.5', - authStore, - }) - - const result = await streamText({ - model, - system: 'You are a helpful assistant. Show your reasoning.', - prompt: 'Think step by step about what 17 * 23 equals, then give me just the answer.', - providerOptions: { - openai: { - store: false, - include: ['reasoning.encrypted_content'], - reasoningEffort: 'low', - reasoningSummary: 'auto', - }, - }, - }) - - const parts: any[] = [] - for await (const part of result.fullStream) { - parts.push(part) - if (part.type === 'reasoning-start' || part.type === 'reasoning-end') { - console.log('Reasoning part:', JSON.stringify(part, null, 2)) - } - } - - // Find reasoning parts with metadata - const reasoningParts = parts.filter((p) => p.type === 'reasoning-start' || p.type === 'reasoning-end') - console.log(`Found ${reasoningParts.length} reasoning parts`) - - // Check that we got reasoning with proper metadata - const reasoningEnd = parts.find((p) => p.type === 'reasoning-end') - expect(reasoningEnd).toBeDefined() - expect(reasoningEnd?.providerMetadata?.openai?.itemId).toMatch(/^rs_/) - console.log('itemId:', reasoningEnd?.providerMetadata?.openai?.itemId) - console.log( - 'encrypted_content present:', - typeof reasoningEnd?.providerMetadata?.openai?.reasoningEncryptedContent === 'string', - ) - }) - - test('continuation WITHOUT id field - should fail or return empty', async () => { - // First, get a real reasoning response to capture metadata - const model = createCodexLanguageModel({ - modelId: 'gpt-5.5', - authStore, - }) - - const initial = await streamText({ - model, - system: 'You are a helpful assistant.', - prompt: 'Think briefly, then say Hello.', - providerOptions: { - openai: { - store: false, - include: ['reasoning.encrypted_content'], - reasoningEffort: 'low', - reasoningSummary: 'auto', - }, - }, - }) - - const initialParts: any[] = [] - for await (const part of initial.fullStream) { - initialParts.push(part) - } - - const reasoningEnd = initialParts.find((p) => p.type === 'reasoning-end') - const textEnd = initialParts.find((p) => p.type === 'text-end') - expect(reasoningEnd).toBeDefined() - - const itemId = reasoningEnd?.providerMetadata?.openai?.itemId - const encryptedContent = reasoningEnd?.providerMetadata?.openai?.reasoningEncryptedContent - console.log('Captured itemId:', itemId) - console.log('Captured encrypted_content:', `${encryptedContent?.slice(0, 50)}...`) - - // Build a continuation request WITHOUT the id field (simulating the bug) - const bodyWithoutId = buildCodexRequestBody( - { - prompt: [ - { role: 'user', content: [{ type: 'text', text: 'Think briefly, then say Hello.' }] }, - { - role: 'assistant', - content: [ - { - type: 'reasoning', - text: 'thinking...', - providerMetadata: { - openai: { - // itemId intentionally OMITTED to simulate bug - reasoningEncryptedContent: encryptedContent, - }, - }, - } as any, - { type: 'text', text: textEnd?.providerMetadata?.openai?.itemId ? '' : 'Hello!' }, - ], - }, - { role: 'user', content: [{ type: 'text', text: 'Now say Goodbye.' }] }, - ], - providerOptions: { - openai: { - store: false, - include: ['reasoning.encrypted_content'], - reasoningEffort: 'low', - }, - }, - }, - 'gpt-5.5', - ) - - console.log('Request body WITHOUT id:', JSON.stringify(bodyWithoutId.input, null, 2)) - - // The reasoning item should NOT have an `id` field - const reasoningInput = bodyWithoutId.input.find((i: any) => i.type === 'reasoning') - expect(reasoningInput).toBeDefined() - expect(reasoningInput?.id).toBeUndefined() // BUG: no id field - console.log('Reasoning input (without id):', JSON.stringify(reasoningInput, null, 2)) - }) - - test('continuation WITHOUT id field - should succeed using encrypted_content only', async () => { - const model = createCodexLanguageModel({ - modelId: 'gpt-5.5', - authStore, - }) - - const initial = await streamText({ - model, - system: 'You are a helpful assistant.', - prompt: 'Think briefly, then say Hello.', - providerOptions: { - openai: { - store: false, - include: ['reasoning.encrypted_content'], - reasoningEffort: 'low', - reasoningSummary: 'auto', - }, - }, - }) - - const initialParts: any[] = [] - for await (const part of initial.fullStream) { - initialParts.push(part) - } - - const reasoningEnd = initialParts.find((p) => p.type === 'reasoning-end') - expect(reasoningEnd).toBeDefined() - - const itemId = reasoningEnd?.providerMetadata?.openai?.itemId - const encryptedContent = reasoningEnd?.providerMetadata?.openai?.reasoningEncryptedContent - - // Build a continuation request — id fields are stripped at the request body level - const body = buildCodexRequestBody( - { - prompt: [ - { role: 'user', content: [{ type: 'text', text: 'Think briefly, then say Hello.' }] }, - { - role: 'assistant', - content: [ - { - type: 'reasoning', - text: 'thinking...', - providerMetadata: { - openai: { - itemId, - reasoningEncryptedContent: encryptedContent, - }, - }, - } as any, - { type: 'text', text: 'Hello!' }, - ], - }, - { role: 'user', content: [{ type: 'text', text: 'Now say Goodbye.' }] }, - ], - providerOptions: { - openai: { - store: false, - include: ['reasoning.encrypted_content'], - reasoningEffort: 'low', - }, - }, - }, - 'gpt-5.5', - ) - - // The reasoning item should NOT have an `id` field (stripped by buildCodexRequestBody) - const reasoningInput = body.input.find((i: any) => i.type === 'reasoning') - expect(reasoningInput).toBeDefined() - expect(reasoningInput?.id).toBeUndefined() - expect(reasoningInput?.encrypted_content).toBeDefined() - - // Now make the actual continuation request — should work without id - const continuation = await streamText({ - model, - system: 'You are a helpful assistant.', - messages: [ - { role: 'user', content: 'Think briefly, then say Hello.' }, - { - role: 'assistant', - content: [ - { - type: 'reasoning', - text: 'thinking...', - providerMetadata: { - openai: { - itemId, - reasoningEncryptedContent: encryptedContent, - }, - }, - } as any, - { type: 'text', text: 'Hello!' }, - ], - }, - { role: 'user', content: 'Now say Goodbye.' }, - ], - providerOptions: { - openai: { - store: false, - include: ['reasoning.encrypted_content'], - reasoningEffort: 'low', - }, - }, - }) - - const contParts: any[] = [] - for await (const part of continuation.fullStream) { - contParts.push(part) - } - - const textDeltas = contParts.filter((p) => p.type === 'text-delta') - const contText = textDeltas.map((p) => p.textDelta || p.text || p.delta || '').join('') - - const hasReasoning = contParts.some((p) => p.type === 'reasoning-start') - const hasText = contText.length > 0 - expect(hasReasoning || hasText).toBe(true) - }) -}) diff --git a/packages/agentlayer-provider-openai-codex/test/service-tier.test.ts b/packages/agentlayer-provider-openai-codex/test/service-tier.test.ts new file mode 100644 index 0000000..6fa4f5d --- /dev/null +++ b/packages/agentlayer-provider-openai-codex/test/service-tier.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from 'bun:test' +import { normalizeCodexServiceTier } from '../src/shared/service-tier' + +// Salvaged from codex-transform.test.ts when the legacy provider was removed: +// normalizeCodexServiceTier is shared and still backs every live transport. +describe('normalizeCodexServiceTier', () => { + test('normalizes fast service tier alias to Codex priority service tier', () => { + expect(normalizeCodexServiceTier('fast')).toBe('priority') + expect(normalizeCodexServiceTier('priority')).toBe('priority') + expect(normalizeCodexServiceTier('flex')).toBe('flex') + expect(normalizeCodexServiceTier(null)).toBeNull() + expect(normalizeCodexServiceTier(undefined)).toBeUndefined() + }) +}) diff --git a/packages/agentlayer-provider-openai-codex/test/stream-text.test.ts b/packages/agentlayer-provider-openai-codex/test/stream-text.test.ts deleted file mode 100644 index 9c4cd06..0000000 --- a/packages/agentlayer-provider-openai-codex/test/stream-text.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, test } from 'bun:test' -import { createMemoryAuthStore } from '@humanlayer/agentlayer-provider-auth' -import { streamText } from 'ai' -import { createCodexProvider } from '../src' - -describe.skipIf( - !process.env.OPENAI_CODEX_ACCESS_TOKEN || - !process.env.OPENAI_CODEX_REFRESH_TOKEN || - !process.env.OPENAI_CODEX_ACCOUNT_ID, -)('StreamText should work', async () => { - const authStore = createMemoryAuthStore({ - codex: { - kind: 'oauth', - accessToken: process.env.OPENAI_CODEX_ACCESS_TOKEN!, - refreshToken: process.env.OPENAI_CODEX_REFRESH_TOKEN, - expiresAt: Date.now() + 60 * 60 * 1000, - accountId: process.env.OPENAI_CODEX_ACCOUNT_ID, - }, - // or: - // codex: { kind: 'api', apiKey: process.env.OPENAI_API_KEY! }, - }) - - const codex = createCodexProvider({ - authStore, - version: '0.0.0-dev', - sessionId: 'local-test-session', - }) - - test( - 'Basic Streaming', - async () => { - const result = streamText({ - model: codex.languageModel('gpt-5.4'), - providerOptions: { - openai: { - reasoningEffort: 'high', - reasoningSummary: 'auto', - include: ['reasoning.encrypted_content'], - }, - }, - system: 'Think hard before answering', - prompt: 'Think hard about `this` in an arrow function inside a class in JS and tell me what this will refer to.', - }) - - for await (const chunk of result.fullStream) { - process.stdout.write(`${JSON.stringify(chunk)}\n`) - } - - expect((await result.reasoning).at(0)!.text).toBeString() - - console.log('\n\nreasoning\n----\n') - console.log(await result.reasoning) - console.log('\n\ntext\n----\n') - console.log(await result.text) - }, - { timeout: 20_000 }, - ) -}) diff --git a/packages/docs/content/packages/openai-codex/index.md b/packages/docs/content/packages/openai-codex/index.md index c745689..63b36f3 100644 --- a/packages/docs/content/packages/openai-codex/index.md +++ b/packages/docs/content/packages/openai-codex/index.md @@ -13,10 +13,10 @@ bun add @humanlayer/agentlayer-provider-openai-codex @humanlayer/agentlayer-prov Codex CLI fast mode sets `service_tier: "priority"` on the Codex request. AgentLayer exposes the same behavior with `fastMode: true`. ```ts -import { createCodexProvider } from '@humanlayer/agentlayer-provider-openai-codex' +import { createCodexSseVendorProvider } from '@humanlayer/agentlayer-provider-openai-codex' import { createMemoryAuthStore } from '@humanlayer/agentlayer-provider-auth' -const codex = createCodexProvider({ +const codex = createCodexSseVendorProvider({ authStore: createMemoryAuthStore({ codex: { kind: 'oauth', @@ -71,7 +71,7 @@ Supported values: ## Auth Store OAuth Fields -When using the auth store with `createCodexProvider`, you can store OAuth tokens using either canonical field names or aliases: +When using the auth store with `createCodexSseVendorProvider`, you can store OAuth tokens using either canonical field names or aliases: | Field | Aliases | Description | |-------|---------|-------------| @@ -85,7 +85,7 @@ When using the auth store with `createCodexProvider`, you can store OAuth tokens Example with aliases: ```ts -import { createCodexProvider } from '@humanlayer/agentlayer-provider-openai-codex' +import { createCodexSseVendorProvider } from '@humanlayer/agentlayer-provider-openai-codex' import { ensureFileAuthStore } from '@humanlayer/agentlayer-provider-auth' const authStore = await ensureFileAuthStore() @@ -96,6 +96,6 @@ await authStore.set('codex', { expires: 1234567890, }) -const codex = createCodexProvider({ authStore, fastMode: true }) +const codex = createCodexSseVendorProvider({ authStore, fastMode: true }) const model = codex.languageModel('gpt-5.4') ```