Skip to content

Commit b5c07d4

Browse files
fix(ai): step 3 — preserve Luna headroom and diagnostics
Checkpoint: keeps a safe completion cap and logs sanitized provider error metadata. Reviewed against testing/hotfix-luna-chat-no-explicit-reasoning.md.
1 parent 71b9f3e commit b5c07d4

3 files changed

Lines changed: 80 additions & 9 deletions

File tree

web/src/lib/ai-coach.test.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@ function mockOpenAi(content: string, ok = true, status = 200) {
2222
} as unknown as Response);
2323
}
2424

25+
function mockOpenAiError(message: string, status = 400) {
26+
return vi.fn().mockResolvedValue({
27+
ok: false,
28+
status,
29+
headers: new Headers({ "x-request-id": "req-test" }),
30+
json: async () => ({
31+
error: {
32+
message,
33+
type: "invalid_request_error",
34+
param: "reasoning_effort",
35+
code: "unsupported_value",
36+
},
37+
}),
38+
} as unknown as Response);
39+
}
40+
2541
function mockOpenAiStream(content: string) {
2642
const body = new ReadableStream({
2743
start(controller) {
@@ -101,8 +117,18 @@ describe("runCoach", () => {
101117
});
102118

103119
it("throws on an OpenAI HTTP error", async () => {
104-
vi.stubGlobal("fetch", mockOpenAi("", false, 429));
105-
await expect(runCoach("sk-test", "gpt-4.1-nano", baseReq)).rejects.toThrow("openai_429");
120+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
121+
vi.stubGlobal("fetch", mockOpenAiError("Unsupported value", 400));
122+
await expect(runCoach("sk-test", "gpt-4.1-nano", baseReq)).rejects.toThrow("openai_400");
123+
expect(errorSpy).toHaveBeenCalledWith(
124+
"[ai-coach] OpenAI request failed",
125+
expect.objectContaining({
126+
operation: "explain",
127+
status: 400,
128+
requestId: "req-test",
129+
providerError: expect.objectContaining({ param: "reasoning_effort" }),
130+
})
131+
);
106132
});
107133

108134
it("throws when the model returns no usable content", async () => {
@@ -186,7 +212,7 @@ describe("reasoning model tuning", () => {
186212
const none = completionTuning("gpt-5.6-luna", { temperature: 0.4, maxTokens: 400, effort: "none" });
187213
const low = completionTuning("gpt-5.6-luna", { temperature: 0.4, maxTokens: 400, effort: "low" });
188214
const max = completionTuning("gpt-5.6-luna", { temperature: 0.4, maxTokens: 400, effort: "max" });
189-
expect(omitted).toEqual({ max_completion_tokens: 4400 });
215+
expect(omitted).toEqual({ max_completion_tokens: 25400 });
190216
expect(none.max_completion_tokens).toBe(400);
191217
expect(low.max_completion_tokens).toBe(2400);
192218
expect(max.max_completion_tokens).toBe(25400);

web/src/lib/ai-coach.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,9 @@ export function completionTuning(
138138
// lower efforts stay deliberately tighter for latency-sensitive background
139139
// calls.
140140
const headroom =
141-
effort === "none"
141+
!opts.effort
142+
? 25_000
143+
: effort === "none"
142144
? 0
143145
: effort === "minimal"
144146
? 1_000
@@ -154,6 +156,33 @@ export function completionTuning(
154156
max_completion_tokens: opts.maxTokens + headroom,
155157
};
156158
}
159+
160+
async function openAiHttpError(res: Response, operation: string): Promise<Error> {
161+
let providerError: Record<string, string> = {};
162+
try {
163+
const payload = (await res.json()) as {
164+
error?: { message?: unknown; type?: unknown; param?: unknown; code?: unknown };
165+
};
166+
const error = payload.error;
167+
const copy = (key: "message" | "type" | "param" | "code", max = 500) => {
168+
const value = error?.[key];
169+
if (typeof value === "string" && value) providerError[key] = value.slice(0, max);
170+
};
171+
copy("message");
172+
copy("type", 100);
173+
copy("param", 100);
174+
copy("code", 100);
175+
} catch {
176+
providerError = { message: "unreadable_error_body" };
177+
}
178+
console.error("[ai-coach] OpenAI request failed", {
179+
operation,
180+
status: res.status,
181+
requestId: res.headers?.get("x-request-id") || undefined,
182+
providerError,
183+
});
184+
return new Error(`openai_${res.status}`);
185+
}
157186
// Enforced monthly caps derive from the advertised tiers in site.ts, so the
158187
// number a user is billed against is the same number the pricing UI shows.
159188
// Overridable via FREE_/PRO_/MAX_AI_CALLS_PER_MONTH env.
@@ -369,9 +398,7 @@ export async function runCoach(
369398
}),
370399
});
371400

372-
if (!res.ok) {
373-
throw new Error(`openai_${res.status}`);
374-
}
401+
if (!res.ok) throw await openAiHttpError(res, req.mode);
375402

376403
const data = (await res.json()) as {
377404
choices?: { message?: { content?: string } }[];
@@ -450,7 +477,7 @@ async function runChatCoach(
450477
}),
451478
});
452479

453-
if (!res.ok) throw new Error(`openai_${res.status}`);
480+
if (!res.ok) throw await openAiHttpError(res, "chat");
454481

455482
const data = (await res.json()) as { choices?: { message?: { content?: string } }[] };
456483
const reply = (data.choices?.[0]?.message?.content || "").trim();
@@ -480,7 +507,7 @@ export async function* streamChatCoach(
480507
}),
481508
});
482509

483-
if (!res.ok) throw new Error(`openai_${res.status}`);
510+
if (!res.ok) throw await openAiHttpError(res, "chat_stream");
484511
if (!res.body) throw new Error("openai_no_body");
485512

486513
const reader = res.body.getReader();

web/src/lib/reasoning-callers.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,24 @@ describe("reasoning-model callers", () => {
6969
expect(body).not.toHaveProperty("max_tokens");
7070
});
7171

72+
it("omits the default notebook effort while retaining reasoning headroom", async () => {
73+
const fetchMock = mockOpenAi({ weakSpots: ["verbs"], reviewPrompts: ["Use 見る in a sentence."] });
74+
vi.stubGlobal("fetch", fetchMock);
75+
const notebook: Notebook = {
76+
id: "n2",
77+
name: "Episode two",
78+
createdAt: "2026-08-06T00:00:00Z",
79+
updatedAt: "2026-08-06T00:00:00Z",
80+
entries: [],
81+
};
82+
83+
await runNotebookSummary("sk-test", "gpt-5.6-luna", notebook);
84+
85+
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
86+
expect(body).not.toHaveProperty("reasoning_effort");
87+
expect(body.max_completion_tokens).toBe(25_600);
88+
});
89+
7290
it("preserves classic sampling fields for a non-reasoning override", async () => {
7391
const fetchMock = mockOpenAi({ word: "見る" });
7492
vi.stubGlobal("fetch", fetchMock);

0 commit comments

Comments
 (0)