mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
A Codex backend 400 on the richer Coach tool-call request (and the daily briefing) propagated out of the provider chain runner unwrapped: a 4xx other than 401/403/429 was not classified as a hard provider failure, so it bypassed AllProvidersFailedError and rethrew from the chat route as an HTTP 500. Plain status-card completions, which never send a tools array, were unaffected. Harden the failure path so a provider error can never surface as a 500: - Treat any 4xx a provider wire returns as a hard failure so it cascades and is wrapped in AllProvidersFailedError. InsightSchemaError (the strict-JSON 422 surface) stays the only non-hard class, so that diagnostic is preserved. - The chat route now also classifies a tagged provider error that reaches its catch unwrapped and emits the graceful coach.provider.* SSE frame instead of rethrowing. Only an untagged error keeps the 500 + error-reporter path. Make the Codex 400 diagnosable: fold the redacted upstream response body into the thrown error message and the chain wide-event, so the field the backend rejects is visible in logs (it was previously dropped). Fix the Codex request shape behind the tool-call 400: - Send the required strict flag on each Responses-API function tool. - Replay prior assistant turns as output_text, not input_text, per the Responses API multi-turn contract — the violation only surfaced on round two of the tool loop, which is the path that broke. Codex-only; the OpenAI, Anthropic, local and mock providers are untouched.
420 lines
13 KiB
TypeScript
420 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { CodexClient } from "../codex-client";
|
|
import { singleUserTurn } from "../types";
|
|
|
|
/**
|
|
* Build a `Response` whose `.body` streams a list of SSE event strings.
|
|
* Mirrors the wire format the Codex backend emits (each event terminated
|
|
* with `\n\n`).
|
|
*/
|
|
function sseResponse(events: string[]): Response {
|
|
const encoder = new TextEncoder();
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
for (const ev of events) controller.enqueue(encoder.encode(ev));
|
|
controller.close();
|
|
},
|
|
});
|
|
return new Response(stream, {
|
|
status: 200,
|
|
headers: { "content-type": "text/event-stream" },
|
|
});
|
|
}
|
|
|
|
const messageDoneEvent = (text: string) =>
|
|
`event: response.output_item.done\ndata: ${JSON.stringify({
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "message",
|
|
role: "assistant",
|
|
content: [{ type: "output_text", text }],
|
|
},
|
|
})}\n\n`;
|
|
|
|
const completedEvent = (totalTokens = 50) =>
|
|
`event: response.completed\ndata: ${JSON.stringify({
|
|
type: "response.completed",
|
|
response: { id: "resp1", usage: { total_tokens: totalTokens } },
|
|
})}\n\n`;
|
|
|
|
describe("CodexClient", () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("assembles assistant text from output_item.done events", async () => {
|
|
const mockFetch = vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
sseResponse([messageDoneEvent('{"summary":"ok"}'), completedEvent(50)]),
|
|
);
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const client = new CodexClient({
|
|
accessToken: "test-token",
|
|
accountId: "acct-test",
|
|
onTokenRefresh: vi
|
|
.fn()
|
|
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
|
|
});
|
|
|
|
const result = await client.generateCompletion(
|
|
singleUserTurn({ system: "You are a doctor.", user: "Analyze this." }),
|
|
);
|
|
|
|
expect(result.content).toBe('{"summary":"ok"}');
|
|
expect(result.providerType).toBe("codex");
|
|
expect(result.tokensUsed).toBe(50);
|
|
expect(mockFetch.mock.calls[0][0]).toBe(
|
|
"https://chatgpt.com/backend-api/codex/responses",
|
|
);
|
|
const sentBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(sentBody.stream).toBe(true);
|
|
expect(Array.isArray(sentBody.input)).toBe(true);
|
|
expect(sentBody.input[0].content[0]).toEqual({
|
|
type: "input_text",
|
|
text: "Analyze this.",
|
|
});
|
|
const headers = mockFetch.mock.calls[0][1].headers;
|
|
expect(headers.Authorization).toBe("Bearer test-token");
|
|
});
|
|
|
|
it("falls back to concatenated output_text deltas when no done event arrived", async () => {
|
|
const deltaEvent = (delta: string) =>
|
|
`event: response.output_text.delta\ndata: ${JSON.stringify({
|
|
type: "response.output_text.delta",
|
|
delta,
|
|
})}\n\n`;
|
|
const mockFetch = vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
sseResponse([
|
|
deltaEvent("Hello "),
|
|
deltaEvent("world"),
|
|
completedEvent(),
|
|
]),
|
|
);
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const client = new CodexClient({
|
|
accessToken: "test-token",
|
|
accountId: "acct-test",
|
|
onTokenRefresh: vi
|
|
.fn()
|
|
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
|
|
});
|
|
const result = await client.generateCompletion(
|
|
singleUserTurn({ system: "test", user: "test" }),
|
|
);
|
|
expect(result.content).toBe("Hello world");
|
|
});
|
|
|
|
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(
|
|
sseResponse([messageDoneEvent('{"test":true}'), completedEvent()]),
|
|
);
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const client = new CodexClient({
|
|
accessToken: "expired-token",
|
|
accountId: "acct-test",
|
|
onTokenRefresh: async () => {
|
|
const t = await onRefresh();
|
|
return { accessToken: t, accountId: "acct-test" };
|
|
},
|
|
});
|
|
|
|
const result = await client.generateCompletion(
|
|
singleUserTurn({ system: "test", user: "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",
|
|
accountId: "acct-test",
|
|
onTokenRefresh: async () => {
|
|
const t = await onRefresh();
|
|
return { accessToken: t, accountId: "acct-test" };
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
client.generateCompletion(
|
|
singleUserTurn({ system: "test", user: "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",
|
|
accountId: "acct-test",
|
|
onTokenRefresh: async () => {
|
|
const t = await onRefresh();
|
|
return { accessToken: t, accountId: "acct-test" };
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
client.generateCompletion(
|
|
singleUserTurn({ system: "test", user: "test" }),
|
|
),
|
|
).rejects.toThrow("Codex request failed (500)");
|
|
expect(onRefresh).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("emits input_image content blocks when images are present", async () => {
|
|
const mockFetch = vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
sseResponse([messageDoneEvent('{"summary":"ok"}'), completedEvent(50)]),
|
|
);
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const client = new CodexClient({
|
|
accessToken: "test-token",
|
|
accountId: "acct-test",
|
|
onTokenRefresh: vi
|
|
.fn()
|
|
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
|
|
});
|
|
|
|
await client.generateCompletion(
|
|
singleUserTurn({
|
|
system: "Transcribe.",
|
|
user: "Read this report.",
|
|
images: [{ mediaType: "image/png", dataBase64: "QUJD" }],
|
|
}),
|
|
);
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
const content = body.input[0].content;
|
|
expect(content[0]).toEqual({
|
|
type: "input_text",
|
|
text: "Read this report.",
|
|
});
|
|
expect(content[1]).toEqual({
|
|
type: "input_image",
|
|
image_url: "data:image/png;base64,QUJD",
|
|
detail: "high",
|
|
});
|
|
});
|
|
|
|
it("keeps the text-only content shape when no images are present", async () => {
|
|
const mockFetch = vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
sseResponse([messageDoneEvent('{"summary":"ok"}'), completedEvent(50)]),
|
|
);
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const client = new CodexClient({
|
|
accessToken: "test-token",
|
|
accountId: "acct-test",
|
|
onTokenRefresh: vi
|
|
.fn()
|
|
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
|
|
});
|
|
|
|
await client.generateCompletion(
|
|
singleUserTurn({ system: "Answer.", user: "Plain text only." }),
|
|
);
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
expect(body.input[0].content).toEqual([
|
|
{ type: "input_text", text: "Plain text only." },
|
|
]);
|
|
});
|
|
|
|
it("maps tool defs and parses function_call items + cached tokens", async () => {
|
|
const functionCallEvent = `event: response.output_item.done\ndata: ${JSON.stringify(
|
|
{
|
|
type: "response.output_item.done",
|
|
item: {
|
|
type: "function_call",
|
|
call_id: "fc_1",
|
|
name: "fetch_glucose",
|
|
arguments: '{"window":"last30days"}',
|
|
},
|
|
},
|
|
)}\n\n`;
|
|
const completedWithCache = `event: response.completed\ndata: ${JSON.stringify(
|
|
{
|
|
type: "response.completed",
|
|
response: {
|
|
id: "resp1",
|
|
usage: {
|
|
total_tokens: 30,
|
|
input_tokens_details: { cached_tokens: 20 },
|
|
},
|
|
},
|
|
},
|
|
)}\n\n`;
|
|
const mockFetch = vi
|
|
.fn()
|
|
.mockResolvedValue(sseResponse([functionCallEvent, completedWithCache]));
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const client = new CodexClient({
|
|
accessToken: "test-token",
|
|
accountId: "acct-test",
|
|
onTokenRefresh: vi
|
|
.fn()
|
|
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
|
|
});
|
|
|
|
const result = await client.generateCompletion(
|
|
singleUserTurn({
|
|
system: "s",
|
|
user: "u",
|
|
tools: [
|
|
{
|
|
name: "fetch_glucose",
|
|
description: "Fetch glucose readings",
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
// v1.21.3 — the Responses-API function tool carries an explicit `strict`
|
|
// field; omitting it is a documented 400 cause on a client-supplied tools
|
|
// array (the live Coach tool-call regression).
|
|
expect(body.tools).toEqual([
|
|
{
|
|
type: "function",
|
|
name: "fetch_glucose",
|
|
description: "Fetch glucose readings",
|
|
strict: false,
|
|
parameters: { type: "object", properties: {} },
|
|
},
|
|
]);
|
|
expect(result.toolCalls).toEqual([
|
|
{
|
|
id: "fc_1",
|
|
name: "fetch_glucose",
|
|
arguments: '{"window":"last30days"}',
|
|
},
|
|
]);
|
|
expect(result.finishReason).toBe("tool_calls");
|
|
expect(result.cachedInputTokens).toBe(20);
|
|
});
|
|
|
|
it("replays a prior assistant turn as output_text, not input_text (Responses API multi-round contract)", async () => {
|
|
// v1.21.3 — assistant message replays MUST use `output_text`. Sending
|
|
// `input_text` for an assistant turn is a spec violation the Coach tool
|
|
// loop hit on round 2+, surfacing as a 400. User turns keep input_text.
|
|
const mockFetch = vi
|
|
.fn()
|
|
.mockResolvedValue(
|
|
sseResponse([messageDoneEvent("final"), completedEvent(10)]),
|
|
);
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const client = new CodexClient({
|
|
accessToken: "test-token",
|
|
accountId: "acct-test",
|
|
onTokenRefresh: vi
|
|
.fn()
|
|
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
|
|
});
|
|
|
|
await client.generateCompletion({
|
|
system: "s",
|
|
messages: [
|
|
{ role: "user", content: "how is my bp?" },
|
|
{ role: "assistant", content: "Let me check." },
|
|
{ role: "user", content: "thanks" },
|
|
],
|
|
});
|
|
|
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
const messageItems = body.input.filter(
|
|
(i: { type: string }) => i.type === "message",
|
|
);
|
|
const userItem = messageItems.find(
|
|
(i: { role: string }) => i.role === "user",
|
|
);
|
|
const assistantItem = messageItems.find(
|
|
(i: { role: string }) => i.role === "assistant",
|
|
);
|
|
expect(userItem.content[0].type).toBe("input_text");
|
|
expect(assistantItem.content[0].type).toBe("output_text");
|
|
expect(assistantItem.content[0].text).toBe("Let me check.");
|
|
});
|
|
|
|
it("folds the redacted upstream body into the thrown 400 message for diagnosability", async () => {
|
|
// v1.21.3 — the 400 body names the rejected field/param. It must reach the
|
|
// error message (the chain runner's summariseError reads err.message) so
|
|
// the live failure is diagnosable, with bearer/sk- secrets redacted.
|
|
const mockFetch = vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 400,
|
|
text: async () =>
|
|
'Bearer secret-token-here {"error":{"message":"Unknown parameter: tools[0].strict"}}',
|
|
});
|
|
vi.stubGlobal("fetch", mockFetch);
|
|
|
|
const client = new CodexClient({
|
|
accessToken: "test-token",
|
|
accountId: "acct-test",
|
|
onTokenRefresh: vi
|
|
.fn()
|
|
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
|
|
});
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await client.generateCompletion(
|
|
singleUserTurn({ system: "s", user: "u" }),
|
|
);
|
|
} catch (e) {
|
|
caught = e;
|
|
}
|
|
const e = caught as Error & { httpStatus?: number; bodyExcerpt?: string };
|
|
expect(e.httpStatus).toBe(400);
|
|
expect(e.message).toContain("Codex request failed (400)");
|
|
expect(e.message).toContain("Unknown parameter");
|
|
// Secret redacted in both the message and the side property.
|
|
expect(e.message).not.toContain("secret-token-here");
|
|
expect(e.bodyExcerpt).toContain("Bearer ***redacted***");
|
|
});
|
|
});
|