mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
80ad0b8851
commit
0f2d258f26
2 files changed
+194
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user