From 0f2d258f2690951b592aea414a217c124d48a839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 6 Apr 2026 00:21:01 +0200 Subject: [PATCH] feat(ai): add Codex client with auto-refresh on 401 TDD implementation of CodexClient that calls the Codex responses endpoint with OAuth Bearer token and automatically retries once after refreshing the token on 401 responses. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/ai/__tests__/codex-client.test.ts | 116 ++++++++++++++++++++++ src/lib/ai/codex-client.ts | 78 +++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 src/lib/ai/__tests__/codex-client.test.ts create mode 100644 src/lib/ai/codex-client.ts diff --git a/src/lib/ai/__tests__/codex-client.test.ts b/src/lib/ai/__tests__/codex-client.test.ts new file mode 100644 index 000000000..653fcd15e --- /dev/null +++ b/src/lib/ai/__tests__/codex-client.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { CodexClient } from "../codex-client"; + +describe("CodexClient", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("sends request to codex responses endpoint", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + output: [ + { type: "message", content: [{ type: "output_text", text: '{"summary":"ok"}' }] }, + ], + usage: { total_tokens: 50 }, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const client = new CodexClient({ + accessToken: "test-token", + onTokenRefresh: vi.fn(), + }); + + const result = await client.generateCompletion({ + systemPrompt: "You are a doctor.", + userPrompt: "Analyze this.", + }); + + expect(result.content).toBe('{"summary":"ok"}'); + expect(result.providerType).toBe("codex"); + expect(mockFetch.mock.calls[0][0]).toBe( + "https://chatgpt.com/backend-api/codex/responses", + ); + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers.Authorization).toBe("Bearer test-token"); + }); + + it("calls onTokenRefresh on 401 and retries", async () => { + const newToken = "refreshed-token"; + const onRefresh = vi.fn().mockResolvedValue(newToken); + + const mockFetch = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 401, text: () => Promise.resolve("unauthorized") }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + output: [ + { type: "message", content: [{ type: "output_text", text: '{"test":true}' }] }, + ], + usage: { total_tokens: 10 }, + }), + }); + vi.stubGlobal("fetch", mockFetch); + + const client = new CodexClient({ + accessToken: "expired-token", + onTokenRefresh: onRefresh, + }); + + const result = await client.generateCompletion({ + systemPrompt: "test", + userPrompt: "test", + }); + + expect(onRefresh).toHaveBeenCalledOnce(); + expect(result.content).toBe('{"test":true}'); + expect(mockFetch.mock.calls[1][1].headers.Authorization).toBe(`Bearer ${newToken}`); + }); + + it("throws after retry if still 401", async () => { + const onRefresh = vi.fn().mockResolvedValue("still-bad"); + + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + text: () => Promise.resolve("unauthorized"), + }); + vi.stubGlobal("fetch", mockFetch); + + const client = new CodexClient({ + accessToken: "bad", + onTokenRefresh: onRefresh, + }); + + await expect( + client.generateCompletion({ systemPrompt: "test", userPrompt: "test" }), + ).rejects.toThrow("Codex request failed after token refresh (401)"); + }); + + it("throws on non-401 errors without retry", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: () => Promise.resolve("server error"), + }), + ); + + const onRefresh = vi.fn(); + const client = new CodexClient({ + accessToken: "test", + onTokenRefresh: onRefresh, + }); + + await expect( + client.generateCompletion({ systemPrompt: "test", userPrompt: "test" }), + ).rejects.toThrow("Codex request failed (500)"); + expect(onRefresh).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts new file mode 100644 index 000000000..460b1b7a6 --- /dev/null +++ b/src/lib/ai/codex-client.ts @@ -0,0 +1,78 @@ +import type { AIProvider, CompletionParams, CompletionResult } from "./types"; + +const CODEX_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"; +const CODEX_MODEL = "gpt-5.3-codex"; + +interface CodexClientConfig { + accessToken: string; + onTokenRefresh: () => Promise; +} + +export class CodexClient implements AIProvider { + readonly type = "codex" as const; + private accessToken: string; + private onTokenRefresh: () => Promise; + + constructor(config: CodexClientConfig) { + this.accessToken = config.accessToken; + this.onTokenRefresh = config.onTokenRefresh; + } + + async generateCompletion(params: CompletionParams): Promise { + const firstAttempt = await this.doRequest(params); + + if (firstAttempt.status === 401) { + this.accessToken = await this.onTokenRefresh(); + const retryAttempt = await this.doRequest(params); + if (!retryAttempt.ok) { + throw new Error(`Codex request failed after token refresh (${retryAttempt.status})`); + } + return this.parseResponse(retryAttempt); + } + + if (!firstAttempt.ok) { + const body = await firstAttempt.text(); + throw new Error(`Codex request failed (${firstAttempt.status}): ${body}`); + } + + return this.parseResponse(firstAttempt); + } + + private async doRequest(params: CompletionParams): Promise { + return fetch(CODEX_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.accessToken}`, + }, + body: JSON.stringify({ + model: CODEX_MODEL, + instructions: params.systemPrompt, + input: params.userPrompt, + stream: false, + }), + }); + } + + private async parseResponse(res: Response): Promise { + const json = await res.json(); + + const messageOutput = json.output?.find( + (o: any) => o.type === "message", + ); + const textContent = messageOutput?.content?.find( + (c: any) => c.type === "output_text", + ); + + if (!textContent?.text) { + throw new Error("Codex returned empty content"); + } + + return { + content: textContent.text, + tokensUsed: json.usage?.total_tokens ?? null, + model: CODEX_MODEL, + providerType: "codex", + }; + } +}