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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.6 committed 2026-04-06 00:42:46 +02:00
1 parent 80ad0b8851
commit 0f2d258f26
2 files changed
+194

No files matched your search

+116
View File
@@ -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();
});
});
+78
View File
@@ -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<string>;
}
export class CodexClient implements AIProvider {
readonly type = "codex" as const;
private accessToken: string;
private onTokenRefresh: () => Promise<string>;
constructor(config: CodexClientConfig) {
this.accessToken = config.accessToken;
this.onTokenRefresh = config.onTokenRefresh;
}
async generateCompletion(params: CompletionParams): Promise<CompletionResult> {
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<Response> {
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<CompletionResult> {
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",
};
}
}