From 8886fc20492a96d404ca407f525b2f7379c0aea0 Mon Sep 17 00:00:00 2001 From: Agent <_@pepijn.ai> Date: Fri, 14 Aug 2026 10:06:06 +0000 Subject: [PATCH 1/8] Stop dropping cache_write_tokens and the provider's own uncached count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two token fields reported by providers were being discarded on their way to costing: 1. The legacy aisdk-codex path (mapCodexUsage) hardcoded cacheWrite: undefined. GPT-5.6 reports input_tokens_details.cache_write_tokens (billed at 1.25x the input rate; openai/codex#32479 is Codex CLI fixing the same drop, and openai-python 2.45 made the field required). Dropped writes ride inside the uncached bucket at 1.0x — undercosted and invisible. The vendor/SSE path already parses the field since the opencode-llm-vendor rewrite; this closes the remaining path, with noCache now subtracting both cache counters. 2. TokenUsageAccumulator derived uncached input by subtraction even though the AI SDK carries the provider's own figure (inputTokenDetails.noCacheTokens). extractUsage now passes it through, add() sums it with poisoning semantics (one call without it makes the model's sum undefined rather than a misleading partial), and snapshot() prefers it over the derived value — so a provider whose breakdown doesn't perfectly telescope (cache-block rounding) is billed on its own accounting. Field evidence from the riptide dev database (agentlayer 0.0.74/75, 8 weeks): 7,012 codex usage rows totalling 12.8B input tokens and 12.1B cache reads carry cache_creation_tokens = 0 on every single row — zero recorded cache writes against 12B cache reads is a dropped field, not telemetry. Co-Authored-By: Claude Fable 5 --- packages/agentlayer-core/src/token-usage.ts | 43 +++++++++- .../agentlayer-core/test/token-usage.test.ts | 86 +++++++++++++++++++ .../src/legacy.ts | 10 ++- .../test/codex-provider.test.ts | 45 ++++++++++ 4 files changed, 180 insertions(+), 4 deletions(-) diff --git a/packages/agentlayer-core/src/token-usage.ts b/packages/agentlayer-core/src/token-usage.ts index 45f0dd8..fded8f2 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,6 +53,9 @@ export function extractUsage(usage: LanguageModelUsage): Omit = {} + // 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. Clamped >= 0 only; the provider's breakdown is + // authoritative even when it doesn't perfectly telescope with the + // cache counters (rounding, cache-block granularity). + const uncachedInputTokens = + usage.noCacheInputTokens !== undefined + ? Math.max(0, usage.noCacheInputTokens) + : usage.inputTokens - cacheReadTokens - cacheWriteTokens const estimatedCostUsd = pricing ? (uncachedInputTokens * pricing.input) / 1_000_000 + (usage.outputTokens * pricing.output) / 1_000_000 + @@ -111,11 +143,20 @@ export class TokenUsageAccumulator { totals.cacheReadTokens += usage.cacheReadTokens totals.cacheWriteTokens += usage.cacheWriteTokens totals.reasoningTokens += usage.reasoningTokens + totalNoCache = + totalNoCache !== undefined && usage.noCacheInputTokens !== undefined + ? totalNoCache + usage.noCacheInputTokens + : undefined 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..b543dd8 100644 --- a/packages/agentlayer-core/test/token-usage.test.ts +++ b/packages/agentlayer-core/test/token-usage.test.ts @@ -123,6 +123,80 @@ describe('TokenUsageAccumulator', () => { expect(acc.snapshot().byModel['provider/model']!.estimatedCostUsd).toBeCloseTo(0.099) }) + test('prefers the provider-reported uncached figure over deriving it by subtraction', () => { + const acc = new TokenUsageAccumulator(() => ({ input: 10, output: 0, cacheRead: 1, cacheWrite: 12.5 })) + // A breakdown that does NOT perfectly telescope (total − read = 200, but + // the provider says 250 uncached — e.g. cache-block rounding). The + // provider's own number wins. + acc.add('provider/model', { + inputTokens: 1_000_000, + outputTokens: 0, + cacheReadTokens: 800_000, + cacheWriteTokens: 0, + reasoningTokens: 0, + noCacheInputTokens: 250_000, + }) + // reported: 250k × $10/M + 800k × $1/M = 3.30. Derived would be 2.80. + expect(acc.snapshot().byModel['provider/model']!.estimatedCostUsd).toBeCloseTo(3.3) + }) + + 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('unknown model has undefined cost', () => { const acc = new TokenUsageAccumulator(() => undefined) acc.add('unknown/model', { @@ -152,6 +226,18 @@ describe('extractUsage', () => { expect(usage.cacheReadTokens).toBe(150) expect(usage.cacheWriteTokens).toBe(50) expect(usage.reasoningTokens).toBe(50) + expect(usage.noCacheInputTokens).toBe(800) + }) + + 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-openai-codex/src/legacy.ts b/packages/agentlayer-provider-openai-codex/src/legacy.ts index d56a92e..b513e47 100644 --- a/packages/agentlayer-provider-openai-codex/src/legacy.ts +++ b/packages/agentlayer-provider-openai-codex/src/legacy.ts @@ -174,7 +174,10 @@ interface CodexResponseFinishedEvent { incomplete_details?: { reason?: string } | null usage?: { input_tokens: number - input_tokens_details?: { cached_tokens?: number | null } | null + // cache_write_tokens: GPT-5.6 started reporting prompt-cache writes + // (billed at 1.25x the uncached input rate); older models omit it. + // Both counters are SUBSETS of input_tokens. + input_tokens_details?: { cached_tokens?: number | null; cache_write_tokens?: number | null } | null output_tokens: number output_tokens_details?: { reasoning_tokens?: number | null } | null } | null @@ -1396,6 +1399,7 @@ function mapCodexFinishReason(reason?: string, hasFunctionCall = false): Languag function mapCodexUsage(usage?: CodexResponseFinishedEvent['response']['usage']): LanguageModelV3Usage { const inputTotal = usage?.input_tokens const cacheRead = usage?.input_tokens_details?.cached_tokens ?? undefined + const cacheWrite = usage?.input_tokens_details?.cache_write_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 @@ -1403,9 +1407,9 @@ function mapCodexUsage(usage?: CodexResponseFinishedEvent['response']['usage']): return { inputTokens: { total: inputTotal, - noCache: inputTotal != null ? Math.max(inputTotal - (cacheRead ?? 0), 0) : undefined, + noCache: inputTotal != null ? Math.max(inputTotal - (cacheRead ?? 0) - (cacheWrite ?? 0), 0) : undefined, cacheRead, - cacheWrite: undefined, + cacheWrite, }, outputTokens: { total: outputTotal, diff --git a/packages/agentlayer-provider-openai-codex/test/codex-provider.test.ts b/packages/agentlayer-provider-openai-codex/test/codex-provider.test.ts index 1e41582..5cf145f 100644 --- a/packages/agentlayer-provider-openai-codex/test/codex-provider.test.ts +++ b/packages/agentlayer-provider-openai-codex/test/codex-provider.test.ts @@ -195,6 +195,51 @@ describe('codex provider wrapper', () => { }) }) + test('carries GPT-5.6 cache_write_tokens through usage instead of dropping them', async () => { + const store = createMemoryAuthStore({ + [CODEX_PROVIDER_ID]: { + kind: 'oauth', + accessToken: 'oauth-access', + accountId: 'acct_123', + }, + }) + const provider = createCodexProvider({ + authStore: store, + version: '1.2.3', + sessionId: 'session-abc', + fetch: async () => + createSseResponse([ + { type: 'response.created', response: { id: 'resp_1', created_at: 1700000000, model: 'gpt-5.6' } }, + { type: 'response.output_item.added', output_index: 0, item: { type: 'message', id: 'msg_1' } }, + { type: 'response.output_text.delta', item_id: 'msg_1', delta: 'Hi' }, + { type: 'response.output_item.done', output_index: 0, item: { type: 'message', id: 'msg_1' } }, + { + type: 'response.completed', + response: { + usage: { + input_tokens: 100, + // Both counters are SUBSETS of input_tokens; cache_write_tokens + // is new with GPT-5.6 (billed at 1.25x the input rate), so + // noCache must subtract BOTH: 100 − 60 − 15 = 25. + input_tokens_details: { cached_tokens: 60, cache_write_tokens: 15 }, + output_tokens: 4, + output_tokens_details: { reasoning_tokens: 1 }, + }, + }, + }, + ]), + }) + + const result = await provider.languageModel('gpt-5.6').doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }], + }) + + expect(result.usage).toEqual({ + inputTokens: { total: 100, noCache: 25, cacheRead: 60, cacheWrite: 15 }, + outputTokens: { total: 4, text: 3, reasoning: 1 }, + }) + }) + test('refreshes expired oauth auth before the request', async () => { const store = createMemoryAuthStore({ [CODEX_PROVIDER_ID]: { From a79620c172d440b9392dcf67c2712f22f25dc345 Mon Sep 17 00:00:00 2001 From: Agent <_@pepijn.ai> Date: Fri, 14 Aug 2026 10:51:00 +0000 Subject: [PATCH 2/8] Apply review round two: reconcile priced categories, cover every Responses transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four confirmed findings from the fresh-context review of this branch: 1. snapshot() trusted a provider-reported noCacheInputTokens with only a >= 0 clamp: a non-telescoping breakdown billed more prompt-category tokens than the prompt contained (the branch's own test billed 1.05M on a 1M prompt), and a pathological figure was unbounded. The priced categories are now reconciled to PARTITION the prompt total — noCache capped to inputTokens, cacheRead/cacheWrite capped to the remainder — and extractUsage clamps a negative provider figure so it can never drag a summed count below zero. 2. Only the legacy codex transport got the cache_write mapping. The vendored copilot Responses adapter had the identical unpatched shape in all four of its usage sites (doGenerate + streaming, schema and mapping): its zod schema now parses cache_write_tokens, both mappers emit cacheWrite and subtract both counters from noCache. The aisdk_responses transport delegates to upstream @ai-sdk/openai and cannot be fixed here — documented as a known gap in the PR. 3. performCompaction's hardcoded usage-key list silently dropped noCacheInputTokens from the compaction event's summaryUsage; it now accumulates with the same poisoning rule as the accumulator. 4. mapCodexUsage fabricated a "provider-reported" noCache by local subtraction even when the backend sent no input_tokens_details, permanently disabling downstream absence-keyed fallbacks; noCache is now reported only when a breakdown exists to derive it from. One assertion that pinned the fabricated value is updated. Co-Authored-By: Claude Fable 5 --- packages/agentlayer-core/src/agent.ts | 8 +++++ packages/agentlayer-core/src/token-usage.ts | 35 +++++++++++++------ .../agentlayer-core/test/token-usage.test.ts | 29 +++++++++++---- .../openai-responses-language-model.ts | 35 +++++++++++++++---- .../src/legacy.ts | 11 +++++- .../test/codex-provider.test.ts | 5 ++- 6 files changed, 98 insertions(+), 25 deletions(-) diff --git a/packages/agentlayer-core/src/agent.ts b/packages/agentlayer-core/src/agent.ts index d436681..e84cbee 100644 --- a/packages/agentlayer-core/src/agent.ts +++ b/packages/agentlayer-core/src/agent.ts @@ -444,6 +444,7 @@ export class Agent> = Record => { @@ -478,6 +479,13 @@ 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 fded8f2..8e76512 100644 --- a/packages/agentlayer-core/src/token-usage.ts +++ b/packages/agentlayer-core/src/token-usage.ts @@ -54,8 +54,13 @@ export function extractUsage(usage: LanguageModelUsage): Omit= 0 only; the provider's breakdown is - // authoritative even when it doesn't perfectly telescope with the - // cache counters (rounding, cache-block granularity). + // inclusive total. Whichever side is reported, 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, and a negative + // counter would push a summed category below zero. const uncachedInputTokens = usage.noCacheInputTokens !== undefined - ? Math.max(0, usage.noCacheInputTokens) - : usage.inputTokens - cacheReadTokens - cacheWriteTokens + ? Math.min(Math.max(0, usage.noCacheInputTokens), Math.max(0, usage.inputTokens)) + : undefined + const cacheReadTokens = Math.min( + Math.max(0, usage.cacheReadTokens), + Math.max(0, usage.inputTokens) - (uncachedInputTokens ?? 0), + ) + const cacheWriteTokens = Math.min( + Math.max(0, usage.cacheWriteTokens), + Math.max(0, usage.inputTokens) - (uncachedInputTokens ?? 0) - cacheReadTokens, + ) + const pricedUncachedTokens = + uncachedInputTokens ?? Math.max(0, usage.inputTokens) - 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 diff --git a/packages/agentlayer-core/test/token-usage.test.ts b/packages/agentlayer-core/test/token-usage.test.ts index b543dd8..512e73f 100644 --- a/packages/agentlayer-core/test/token-usage.test.ts +++ b/packages/agentlayer-core/test/token-usage.test.ts @@ -123,11 +123,13 @@ describe('TokenUsageAccumulator', () => { expect(acc.snapshot().byModel['provider/model']!.estimatedCostUsd).toBeCloseTo(0.099) }) - test('prefers the provider-reported uncached figure over deriving it by subtraction', () => { + 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 = 200, but - // the provider says 250 uncached — e.g. cache-block rounding). The - // provider's own number wins. + // 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, @@ -136,8 +138,23 @@ describe('TokenUsageAccumulator', () => { reasoningTokens: 0, noCacheInputTokens: 250_000, }) - // reported: 250k × $10/M + 800k × $1/M = 3.30. Derived would be 2.80. - expect(acc.snapshot().byModel['provider/model']!.estimatedCostUsd).toBeCloseTo(3.3) + // reported: 250k × $10/M + min(800k, 750k) × $1/M = 3.25. Derived would be 2.80. + expect(acc.snapshot().byModel['provider/model']!.estimatedCostUsd).toBeCloseTo(3.25) + }) + + 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', () => { 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..9c3dc71 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,19 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { usage: { inputTokens: { total: response.usage.input_tokens, + // Both cache counters are SUBSETS of input_tokens; cache_write_tokens + // is new with GPT-5.6, so noCache must subtract BOTH when present. noCache: - response.usage.input_tokens_details?.cached_tokens != null - ? response.usage.input_tokens - response.usage.input_tokens_details.cached_tokens + response.usage.input_tokens_details != null + ? Math.max( + response.usage.input_tokens - + (response.usage.input_tokens_details.cached_tokens ?? 0) - + (response.usage.input_tokens_details.cache_write_tokens ?? 0), + 0, + ) : undefined, 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 +819,16 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { totalTokens: number | undefined reasoningTokens: number | undefined cachedInputTokens: number | undefined + cacheWriteInputTokens: number | undefined + hasInputTokenDetails: boolean } = { inputTokens: undefined, outputTokens: undefined, totalTokens: undefined, reasoningTokens: undefined, cachedInputTokens: undefined, + cacheWriteInputTokens: undefined, + hasInputTokenDetails: false, } const logprobs: Array> = [] let responseId: string | null = null @@ -1282,6 +1293,9 @@ 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 + usage.hasInputTokenDetails = value.response.usage.input_tokens_details != null if (typeof value.response.service_tier === 'string') { serviceTier = value.response.service_tier } @@ -1339,11 +1353,16 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { inputTokens: { total: usage.inputTokens, noCache: - usage.inputTokens != null && usage.cachedInputTokens != null - ? usage.inputTokens - usage.cachedInputTokens + usage.inputTokens != null && usage.hasInputTokenDetails + ? Math.max( + usage.inputTokens - + (usage.cachedInputTokens ?? 0) - + (usage.cacheWriteInputTokens ?? 0), + 0, + ) : undefined, cacheRead: usage.cachedInputTokens, - cacheWrite: undefined, + cacheWrite: usage.cacheWriteInputTokens, }, outputTokens: { total: usage.outputTokens, @@ -1370,7 +1389,9 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { 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/src/legacy.ts b/packages/agentlayer-provider-openai-codex/src/legacy.ts index b513e47..63a7fb3 100644 --- a/packages/agentlayer-provider-openai-codex/src/legacy.ts +++ b/packages/agentlayer-provider-openai-codex/src/legacy.ts @@ -1404,10 +1404,19 @@ function mapCodexUsage(usage?: CodexResponseFinishedEvent['response']['usage']): const reasoning = usage?.output_tokens_details?.reasoning_tokens ?? undefined const text = outputTotal != null ? Math.max(outputTotal - (reasoning ?? 0), 0) : undefined + // noCache is only DERIVED here (total minus the cache subsets), so it is + // reported only when the backend actually sent a breakdown to derive from. + // Fabricating `noCache = total` when input_tokens_details is absent would + // present a guess as a provider-reported figure and permanently disable + // downstream fallbacks that key on its absence. + const hasBreakdown = usage?.input_tokens_details != null return { inputTokens: { total: inputTotal, - noCache: inputTotal != null ? Math.max(inputTotal - (cacheRead ?? 0) - (cacheWrite ?? 0), 0) : undefined, + noCache: + hasBreakdown && inputTotal != null + ? Math.max(inputTotal - (cacheRead ?? 0) - (cacheWrite ?? 0), 0) + : undefined, cacheRead, cacheWrite, }, diff --git a/packages/agentlayer-provider-openai-codex/test/codex-provider.test.ts b/packages/agentlayer-provider-openai-codex/test/codex-provider.test.ts index 5cf145f..704647a 100644 --- a/packages/agentlayer-provider-openai-codex/test/codex-provider.test.ts +++ b/packages/agentlayer-provider-openai-codex/test/codex-provider.test.ts @@ -559,7 +559,10 @@ describe('codex provider wrapper', () => { type: 'finish', finishReason: { unified: 'stop', raw: undefined }, usage: { - inputTokens: { total: 2, noCache: 2, cacheRead: undefined, cacheWrite: undefined }, + // No input_tokens_details in the fixture: noCache stays undefined + // rather than being fabricated from the total, so downstream + // fallbacks that key on its absence keep working. + inputTokens: { total: 2, noCache: undefined, cacheRead: undefined, cacheWrite: undefined }, outputTokens: { total: 2, text: 2, reasoning: undefined }, }, providerMetadata: { openai: { responseId: 'resp_stream' } }, From 081073591b186d3d47f28e1b92189a608b17953d Mon Sep 17 00:00:00 2001 From: Agent <_@pepijn.ai> Date: Fri, 14 Aug 2026 12:24:43 +0000 Subject: [PATCH 3/8] Remove the legacy codex provider and the aisdk_responses transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both verified unused before removal (repo greps in agentlayer and synclayer, GitHub code search across the org, deployment env/infra): - legacy.ts (createCodexProvider and its nine sibling exports) had zero runtime consumers anywhere — it was exported "for backward compat until removed" and only its own tests imported it. - The aisdk_responses transport was selectable via CODEX_PROVIDER but selected nowhere; it delegated SSE parsing to upstream @ai-sdk/openai, whose usage schema drops GPT-5.6's cache_write_tokens — removing it removes this branch's one known token-accounting gap instead of mitigating it. CodexProviderMode narrows to 'sse' | 'websockets'. A daemon still carrying CODEX_PROVIDER=aisdk_responses (documented env escape hatch on a public package) degrades to the default sse transport with a warn rather than crashing. The custom-deployment override path (custom-openai-responses) is untouched — it only shared the diagnostics label. Tests: legacy-only suites deleted (their live-transport coverage already exists in codex-sse-provider.test.ts, including the parametrized cache_write_tokens fixtures); normalizeCodexServiceTier tests salvaged into service-tier.test.ts; agent.test.ts updated for two transports plus a new retired-mode fallback test. READMEs and the docs page now show the two shipping transports. Removing public exports is breaking for the published package — release should bump accordingly. Co-Authored-By: Claude Fable 5 --- agents/codelayer/README.md | 2 +- agents/codelayer/src/providers.ts | 18 +- agents/codelayer/test/agent.test.ts | 21 +- .../README.md | 30 +- .../src/index.ts | 16 +- .../src/legacy.ts | 1433 ----------------- .../providers/aisdk-codex-provider/index.ts | 263 --- .../test/codex-provider.test.ts | 945 ----------- .../test/codex-responses-provider.test.ts | 197 --- .../test/codex-transform.test.ts | 477 ------ .../reasoning-continuation.learning.test.ts | 266 --- .../test/service-tier.test.ts | 14 + .../test/stream-text.test.ts | 58 - .../content/packages/openai-codex/index.md | 10 +- 14 files changed, 51 insertions(+), 3699 deletions(-) delete mode 100644 packages/agentlayer-provider-openai-codex/src/legacy.ts delete mode 100644 packages/agentlayer-provider-openai-codex/src/providers/aisdk-codex-provider/index.ts delete mode 100644 packages/agentlayer-provider-openai-codex/test/codex-provider.test.ts delete mode 100644 packages/agentlayer-provider-openai-codex/test/codex-responses-provider.test.ts delete mode 100644 packages/agentlayer-provider-openai-codex/test/codex-transform.test.ts delete mode 100644 packages/agentlayer-provider-openai-codex/test/reasoning-continuation.learning.test.ts create mode 100644 packages/agentlayer-provider-openai-codex/test/service-tier.test.ts delete mode 100644 packages/agentlayer-provider-openai-codex/test/stream-text.test.ts 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..ad01337 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' @@ -438,9 +437,16 @@ export async function resolveModel( } const authStore = await ensureFileAuthStore() - const codexMode = context?.codexProviderMode - ?? (process.env.CODEX_PROVIDER as CodexProviderMode | undefined) - ?? 'sse' + const requestedMode = context?.codexProviderMode ?? (process.env.CODEX_PROVIDER as string | undefined) + // 'aisdk_responses' was removed (it delegated SSE parsing to upstream + // @ai-sdk/openai, which drops cache_write_tokens); unknown or retired + // 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 +456,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/packages/agentlayer-provider-openai-codex/README.md b/packages/agentlayer-provider-openai-codex/README.md index 5d9f982..997a72b 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: the former had no runtime consumers, and the latter inherited upstream's usage schema, which drops GPT-5.6's `cache_write_tokens`.) -### 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') @@ -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/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 63a7fb3..0000000 --- a/packages/agentlayer-provider-openai-codex/src/legacy.ts +++ /dev/null @@ -1,1433 +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 - // cache_write_tokens: GPT-5.6 started reporting prompt-cache writes - // (billed at 1.25x the uncached input rate); older models omit it. - // Both counters are SUBSETS of input_tokens. - input_tokens_details?: { cached_tokens?: number | null; cache_write_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 cacheWrite = usage?.input_tokens_details?.cache_write_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 - - // noCache is only DERIVED here (total minus the cache subsets), so it is - // reported only when the backend actually sent a breakdown to derive from. - // Fabricating `noCache = total` when input_tokens_details is absent would - // present a guess as a provider-reported figure and permanently disable - // downstream fallbacks that key on its absence. - const hasBreakdown = usage?.input_tokens_details != null - return { - inputTokens: { - total: inputTotal, - noCache: - hasBreakdown && inputTotal != null - ? Math.max(inputTotal - (cacheRead ?? 0) - (cacheWrite ?? 0), 0) - : undefined, - cacheRead, - cacheWrite, - }, - 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/test/codex-provider.test.ts b/packages/agentlayer-provider-openai-codex/test/codex-provider.test.ts deleted file mode 100644 index 704647a..0000000 --- a/packages/agentlayer-provider-openai-codex/test/codex-provider.test.ts +++ /dev/null @@ -1,945 +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('carries GPT-5.6 cache_write_tokens through usage instead of dropping them', async () => { - const store = createMemoryAuthStore({ - [CODEX_PROVIDER_ID]: { - kind: 'oauth', - accessToken: 'oauth-access', - accountId: 'acct_123', - }, - }) - const provider = createCodexProvider({ - authStore: store, - version: '1.2.3', - sessionId: 'session-abc', - fetch: async () => - createSseResponse([ - { type: 'response.created', response: { id: 'resp_1', created_at: 1700000000, model: 'gpt-5.6' } }, - { type: 'response.output_item.added', output_index: 0, item: { type: 'message', id: 'msg_1' } }, - { type: 'response.output_text.delta', item_id: 'msg_1', delta: 'Hi' }, - { type: 'response.output_item.done', output_index: 0, item: { type: 'message', id: 'msg_1' } }, - { - type: 'response.completed', - response: { - usage: { - input_tokens: 100, - // Both counters are SUBSETS of input_tokens; cache_write_tokens - // is new with GPT-5.6 (billed at 1.25x the input rate), so - // noCache must subtract BOTH: 100 − 60 − 15 = 25. - input_tokens_details: { cached_tokens: 60, cache_write_tokens: 15 }, - output_tokens: 4, - output_tokens_details: { reasoning_tokens: 1 }, - }, - }, - }, - ]), - }) - - const result = await provider.languageModel('gpt-5.6').doGenerate({ - prompt: [{ role: 'user', content: [{ type: 'text', text: 'Hi' }] }], - }) - - expect(result.usage).toEqual({ - inputTokens: { total: 100, noCache: 25, cacheRead: 60, cacheWrite: 15 }, - 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: { - // No input_tokens_details in the fixture: noCache stays undefined - // rather than being fabricated from the total, so downstream - // fallbacks that key on its absence keep working. - inputTokens: { total: 2, noCache: undefined, 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') ``` From 4f049b350fd989386a3eb22aad7685e33ca2e1c8 Mon Sep 17 00:00:00 2001 From: Agent <_@pepijn.ai> Date: Fri, 14 Aug 2026 12:47:19 +0000 Subject: [PATCH 4/8] Apply review round three: trust rules, one derivation, dead code out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten findings from the fresh-context review of this branch; all applied: - snapshot() publishes the RECONCILED noCacheInputTokens in byModel and totals (the figure costing actually used), never the raw report — a pathological value could exceed inputTokens and contradict the billed cost for any consumer deriving cached = input − noCache. - A provider noCache below the prompt total with NO cache counters is no longer trusted: pricing it would bill the cached remainder at $0. It falls back to derivation (full input rate, matching pre-noCache behavior). Trust requires cache counters to price the remainder, or a figure covering the whole prompt. - extractUsage treats a NEGATIVE noCacheTokens as absent instead of clamping to 0 — clamped garbage read as "zero uncached tokens, provider-vouched" and billed the whole prompt at $0. - The undefined-poisoning sum rule is one exported sumOrPoison() helper instead of three hand-written copies (add(), snapshot() totals, performCompaction). - The copilot Responses adapter derives noCache through one deriveNoCacheTokens() used by doGenerate and doStream, gated on cache COUNTER presence rather than details-object presence — details: {} no longer fabricates noCache = input_tokens as fact — and the hasInputTokenDetails flag is gone. - resolveCodexAuth's expired-oauth refresh-and-persist path (used by both live transports on every request) regained direct coverage after its only test died with the legacy suite: codex-auth.test.ts. - CODEX_PROVIDER= (empty string from an env template) is treated as unset instead of warning on every model resolution. - Dead code from the removal: the @ai-sdk/openai dependency (zero remaining imports — the very SDK whose schema drops cache_write_tokens), the orphaned wrapSSE watchdog, and DEFAULT_CHUNK_TIMEOUT_MS. The README no longer documents options of the removed provider. Co-Authored-By: Claude Fable 5 --- agents/codelayer/src/providers.ts | 4 +- packages/agentlayer-core/src/agent.ts | 17 ++--- packages/agentlayer-core/src/token-usage.ts | 69 +++++++++++------- .../agentlayer-core/test/token-usage.test.ts | 47 +++++++++++++ .../openai-responses-language-model.ts | 48 +++++++------ .../README.md | 2 +- .../package.json | 1 - .../src/shared/constants.ts | 1 - .../src/shared/sse.ts | 63 ----------------- .../test/codex-auth.test.ts | 70 +++++++++++++++++++ 10 files changed, 197 insertions(+), 125 deletions(-) delete mode 100644 packages/agentlayer-provider-openai-codex/src/shared/sse.ts create mode 100644 packages/agentlayer-provider-openai-codex/test/codex-auth.test.ts diff --git a/agents/codelayer/src/providers.ts b/agents/codelayer/src/providers.ts index ad01337..e86a08c 100644 --- a/agents/codelayer/src/providers.ts +++ b/agents/codelayer/src/providers.ts @@ -437,7 +437,9 @@ export async function resolveModel( } const authStore = await ensureFileAuthStore() - const requestedMode = context?.codexProviderMode ?? (process.env.CODEX_PROVIDER as string | undefined) + 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 // 'aisdk_responses' was removed (it delegated SSE parsing to upstream // @ai-sdk/openai, which drops cache_write_tokens); unknown or retired // values fall back to the default transport instead of crashing a diff --git a/packages/agentlayer-core/src/agent.ts b/packages/agentlayer-core/src/agent.ts index e84cbee..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 @@ -479,13 +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 8e76512..bc5ddf4 100644 --- a/packages/agentlayer-core/src/token-usage.ts +++ b/packages/agentlayer-core/src/token-usage.ts @@ -55,15 +55,28 @@ 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, @@ -102,13 +115,10 @@ export class TokenUsageAccumulator { existing.cacheReadTokens += usage.cacheReadTokens existing.cacheWriteTokens += usage.cacheWriteTokens existing.reasoningTokens += usage.reasoningTokens - // Sums only while EVERY call reported it; one silent call poisons the - // model's sum to undefined so costing falls back to derivation instead - // of billing a partial "uncached" figure as if it covered all calls. - existing.noCacheInputTokens = - existing.noCacheInputTokens !== undefined && usage.noCacheInputTokens !== undefined - ? existing.noCacheInputTokens + usage.noCacheInputTokens - : undefined + // 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 } } @@ -125,25 +135,30 @@ export class TokenUsageAccumulator { const pricing = this.pricingLookup?.(modelKey as ModelKey) // Prefer the provider's own uncached figure over deriving it — the // subtraction is a fallback for providers that only report the - // inclusive total. Whichever side is reported, 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, and a negative - // counter would push a summed category below zero. + // 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) + const hasCacheCounters = Math.max(0, usage.cacheReadTokens) > 0 || Math.max(0, usage.cacheWriteTokens) > 0 const uncachedInputTokens = - usage.noCacheInputTokens !== undefined - ? Math.min(Math.max(0, usage.noCacheInputTokens), Math.max(0, usage.inputTokens)) + usage.noCacheInputTokens !== undefined && (hasCacheCounters || usage.noCacheInputTokens >= promptTotal) + ? Math.min(usage.noCacheInputTokens, promptTotal) : undefined const cacheReadTokens = Math.min( Math.max(0, usage.cacheReadTokens), - Math.max(0, usage.inputTokens) - (uncachedInputTokens ?? 0), + promptTotal - (uncachedInputTokens ?? 0), ) const cacheWriteTokens = Math.min( Math.max(0, usage.cacheWriteTokens), - Math.max(0, usage.inputTokens) - (uncachedInputTokens ?? 0) - cacheReadTokens, + promptTotal - (uncachedInputTokens ?? 0) - cacheReadTokens, ) - const pricedUncachedTokens = - uncachedInputTokens ?? Math.max(0, usage.inputTokens) - cacheReadTokens - cacheWriteTokens + const pricedUncachedTokens = uncachedInputTokens ?? promptTotal - cacheReadTokens - cacheWriteTokens const estimatedCostUsd = pricing ? (pricedUncachedTokens * pricing.input) / 1_000_000 + (usage.outputTokens * pricing.output) / 1_000_000 + @@ -151,17 +166,17 @@ export class TokenUsageAccumulator { (cacheWriteTokens * (pricing.cacheWrite ?? pricing.input)) / 1_000_000 : undefined - byModel[modelKey] = { ...usage, estimatedCostUsd } + // Publish the RECONCILED figure (the one costing used), never the raw + // report: a raw pathological value in byModel/totals could exceed + // inputTokens and contradict the billed cost. + byModel[modelKey] = { ...usage, noCacheInputTokens: uncachedInputTokens, estimatedCostUsd } totals.inputTokens += usage.inputTokens totals.outputTokens += usage.outputTokens totals.cacheReadTokens += usage.cacheReadTokens totals.cacheWriteTokens += usage.cacheWriteTokens totals.reasoningTokens += usage.reasoningTokens - totalNoCache = - totalNoCache !== undefined && usage.noCacheInputTokens !== undefined - ? totalNoCache + usage.noCacheInputTokens - : undefined + totalNoCache = sumOrPoison(totalNoCache, uncachedInputTokens) if (estimatedCostUsd !== undefined) { totals.estimatedCostUsd = (totals.estimatedCostUsd ?? 0) + estimatedCostUsd } diff --git a/packages/agentlayer-core/test/token-usage.test.ts b/packages/agentlayer-core/test/token-usage.test.ts index 512e73f..8b237fa 100644 --- a/packages/agentlayer-core/test/token-usage.test.ts +++ b/packages/agentlayer-core/test/token-usage.test.ts @@ -214,6 +214,41 @@ describe('TokenUsageAccumulator', () => { 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', { @@ -246,6 +281,18 @@ describe('extractUsage', () => { 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, 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 9c3dc71..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,17 +750,11 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { usage: { inputTokens: { total: response.usage.input_tokens, - // Both cache counters are SUBSETS of input_tokens; cache_write_tokens - // is new with GPT-5.6, so noCache must subtract BOTH when present. - noCache: - response.usage.input_tokens_details != null - ? Math.max( - response.usage.input_tokens - - (response.usage.input_tokens_details.cached_tokens ?? 0) - - (response.usage.input_tokens_details.cache_write_tokens ?? 0), - 0, - ) - : 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: response.usage.input_tokens_details?.cache_write_tokens ?? undefined, }, @@ -820,7 +814,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { reasoningTokens: number | undefined cachedInputTokens: number | undefined cacheWriteInputTokens: number | undefined - hasInputTokenDetails: boolean } = { inputTokens: undefined, outputTokens: undefined, @@ -828,7 +821,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { reasoningTokens: undefined, cachedInputTokens: undefined, cacheWriteInputTokens: undefined, - hasInputTokenDetails: false, } const logprobs: Array> = [] let responseId: string | null = null @@ -1295,7 +1287,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { value.response.usage.input_tokens_details?.cached_tokens ?? undefined usage.cacheWriteInputTokens = value.response.usage.input_tokens_details?.cache_write_tokens ?? undefined - usage.hasInputTokenDetails = value.response.usage.input_tokens_details != null if (typeof value.response.service_tier === 'string') { serviceTier = value.response.service_tier } @@ -1352,15 +1343,11 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { usage: { inputTokens: { total: usage.inputTokens, - noCache: - usage.inputTokens != null && usage.hasInputTokenDetails - ? Math.max( - usage.inputTokens - - (usage.cachedInputTokens ?? 0) - - (usage.cacheWriteInputTokens ?? 0), - 0, - ) - : undefined, + noCache: deriveNoCacheTokens( + usage.inputTokens, + usage.cachedInputTokens, + usage.cacheWriteInputTokens, + ), cacheRead: usage.cachedInputTokens, cacheWrite: usage.cacheWriteInputTokens, }, @@ -1387,6 +1374,21 @@ 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 diff --git a/packages/agentlayer-provider-openai-codex/README.md b/packages/agentlayer-provider-openai-codex/README.md index 997a72b..3fbeee0 100644 --- a/packages/agentlayer-provider-openai-codex/README.md +++ b/packages/agentlayer-provider-openai-codex/README.md @@ -58,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 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/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/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') + }) +}) From e59e4bfc61df71ce1419e1839990af483df4bf2e Mon Sep 17 00:00:00 2001 From: Agent <_@pepijn.ai> Date: Fri, 14 Aug 2026 13:42:28 +0000 Subject: [PATCH 5/8] Bump @ai-sdk/openai to 3.0.96: upstream now parses cache_write_tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "known gap" this branch documented — upstream @ai-sdk/openai dropping GPT-5.6's cache_write_tokens — was fixed upstream within our 3.x line (present in 3.0.96, absent in 3.0.80; we pinned 3.0.69). No issue or PR to vercel/ai needed. This closes the last consumer of the broken schema: the custom-deployment override path (custom-openai-responses), which delegates to createOpenAI().responses(). Its captureResponseUsage helper needed one adaptation — read-once-and-rebuild instead of clone(): the new SDK reads the body in a way that races the clone's tee under Bun, surfacing as "JSON Parse error: Unexpected EOF". The aisdk_responses transport removal earlier in this branch stands on its own grounds (zero users); this bump just means no shipping path is left anywhere that drops cache writes. Co-Authored-By: Claude Fable 5 --- agents/codelayer/src/providers.ts | 11 ++++++++--- bun.lock | 15 +++++++++++---- package.json | 2 +- 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/agents/codelayer/src/providers.ts b/agents/codelayer/src/providers.ts index e86a08c..6f94e96 100644 --- a/agents/codelayer/src/providers.ts +++ b/agents/codelayer/src/providers.ts @@ -78,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 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", From dd27b587497c80c61f2439e4ab36c25ae94f68f3 Mon Sep 17 00:00:00 2001 From: Agent <_@pepijn.ai> Date: Fri, 14 Aug 2026 21:04:41 +0000 Subject: [PATCH 6/8] Update two comments the @ai-sdk/openai bump made stale The providers.ts fallback comment and the README said upstream 'drops cache_write_tokens' in the present tense; 3.0.96 (bumped in this branch) parses it. Both now scope the claim to the version that held at removal time. Co-Authored-By: Claude Fable 5 --- agents/codelayer/src/providers.ts | 9 +++++---- packages/agentlayer-provider-openai-codex/README.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/agents/codelayer/src/providers.ts b/agents/codelayer/src/providers.ts index 6f94e96..c813e76 100644 --- a/agents/codelayer/src/providers.ts +++ b/agents/codelayer/src/providers.ts @@ -445,10 +445,11 @@ export async function resolveModel( 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 - // 'aisdk_responses' was removed (it delegated SSE parsing to upstream - // @ai-sdk/openai, which drops cache_write_tokens); unknown or retired - // values fall back to the default transport instead of crashing a - // daemon that still carries the env var. + // 'aisdk_responses' was removed (unused, and at the time it inherited an + // @ai-sdk/openai usage schema that dropped cache_write_tokens — fixed + // upstream in 3.0.96); unknown or retired 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) { diff --git a/packages/agentlayer-provider-openai-codex/README.md b/packages/agentlayer-provider-openai-codex/README.md index 3fbeee0..de6b630 100644 --- a/packages/agentlayer-provider-openai-codex/README.md +++ b/packages/agentlayer-provider-openai-codex/README.md @@ -27,7 +27,7 @@ const { state } = await agent.run({ state: startState([userMessage('Hello')]), s ## Providers -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: the former had no runtime consumers, and the latter inherited upstream's usage schema, which drops GPT-5.6's `cache_write_tokens`.) +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. `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. From 07f77711230b13c1f3497aa1344efa669a89b118 Mon Sep 17 00:00:00 2001 From: Agent <_@pepijn.ai> Date: Sat, 15 Aug 2026 06:13:40 +0000 Subject: [PATCH 7/8] Trim the transport-fallback comment to its behavioral rationale The removed transport's history lives in git and the PR; the code only needs to say why unknown values degrade instead of throwing. Co-Authored-By: Claude Fable 5 --- agents/codelayer/src/providers.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/agents/codelayer/src/providers.ts b/agents/codelayer/src/providers.ts index c813e76..a2b7a0d 100644 --- a/agents/codelayer/src/providers.ts +++ b/agents/codelayer/src/providers.ts @@ -445,11 +445,8 @@ export async function resolveModel( 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 - // 'aisdk_responses' was removed (unused, and at the time it inherited an - // @ai-sdk/openai usage schema that dropped cache_write_tokens — fixed - // upstream in 3.0.96); unknown or retired values fall back to the - // default transport instead of crashing a daemon that still carries - // the env var. + // 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) { From f3e591bff3dbe0a166a685edf0f1c0db137e1c26 Mon Sep 17 00:00:00 2001 From: Agent <_@pepijn.ai> Date: Sat, 15 Aug 2026 06:44:28 +0000 Subject: [PATCH 8/8] Apply the fresh-context review: publish reconciled counters, coverage-gated trust, retire the label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent full-PR review; the blocking finding plus two should-fixes: - snapshot() published RAW cacheRead/cacheWrite in byModel and totals next to the RECONCILED noCacheInputTokens, so the exported breakdown didn't partition the prompt and couldn't re-derive the billed cost (the branch's own test fixture published 1.05M category-tokens for a 1M prompt while billing from 750k). Both now publish the clamped values costing used; the test asserts the counters, not just the cost. - The noCache trust rule gated on cache-counter PRESENCE, so one tiny counter unlocked trust and the uncovered remainder priced at $0 (100-token prompt, read 10, noCache 20 -> 70 tokens free). Trust now requires COVERAGE: reported categories must account for the whole prompt, else derivation. - The custom-deployment diagnostics still labeled its records 'aisdk_responses' — the name of a transport this branch deletes. Renamed to 'custom_responses' in the emit and the CodexDiagnosticTransport union. Co-Authored-By: Claude Fable 5 --- agents/codelayer/src/providers.ts | 2 +- packages/agentlayer-core/src/token-usage.ts | 29 ++++++++++++++----- .../agentlayer-core/test/token-usage.test.ts | 9 +++++- .../src/shared/types.ts | 2 +- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/agents/codelayer/src/providers.ts b/agents/codelayer/src/providers.ts index a2b7a0d..04f1a9f 100644 --- a/agents/codelayer/src/providers.ts +++ b/agents/codelayer/src/providers.ts @@ -269,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, diff --git a/packages/agentlayer-core/src/token-usage.ts b/packages/agentlayer-core/src/token-usage.ts index bc5ddf4..7a4529c 100644 --- a/packages/agentlayer-core/src/token-usage.ts +++ b/packages/agentlayer-core/src/token-usage.ts @@ -145,9 +145,15 @@ export class TokenUsageAccumulator { // breakdown (rounding, cache-block granularity) would bill more // prompt tokens than the prompt contained. const promptTotal = Math.max(0, usage.inputTokens) - const hasCacheCounters = Math.max(0, usage.cacheReadTokens) > 0 || Math.max(0, usage.cacheWriteTokens) > 0 + // 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 && (hasCacheCounters || usage.noCacheInputTokens >= promptTotal) + usage.noCacheInputTokens !== undefined && reportedCoverage >= promptTotal ? Math.min(usage.noCacheInputTokens, promptTotal) : undefined const cacheReadTokens = Math.min( @@ -166,15 +172,22 @@ export class TokenUsageAccumulator { (cacheWriteTokens * (pricing.cacheWrite ?? pricing.input)) / 1_000_000 : undefined - // Publish the RECONCILED figure (the one costing used), never the raw - // report: a raw pathological value in byModel/totals could exceed - // inputTokens and contradict the billed cost. - byModel[modelKey] = { ...usage, noCacheInputTokens: uncachedInputTokens, 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) { diff --git a/packages/agentlayer-core/test/token-usage.test.ts b/packages/agentlayer-core/test/token-usage.test.ts index 8b237fa..b402894 100644 --- a/packages/agentlayer-core/test/token-usage.test.ts +++ b/packages/agentlayer-core/test/token-usage.test.ts @@ -139,7 +139,14 @@ describe('TokenUsageAccumulator', () => { noCacheInputTokens: 250_000, }) // reported: 250k × $10/M + min(800k, 750k) × $1/M = 3.25. Derived would be 2.80. - expect(acc.snapshot().byModel['provider/model']!.estimatedCostUsd).toBeCloseTo(3.25) + 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', () => { 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