Skip to content

Commit 47665af

Browse files
fix(ai): step 1 — omit default Luna reasoning effort
Checkpoint: removes the production max-effort override while preserving explicit caller tuning. Reviewed against testing/hotfix-luna-chat-no-explicit-reasoning.md.
1 parent d0422fb commit 47665af

6 files changed

Lines changed: 40 additions & 29 deletions

File tree

web/.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ NEXT_PUBLIC_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/app
66
# AI coach (issue #13). In production set OPENAI_API_KEY as a Worker secret instead.
77
OPENAI_API_KEY=sk-your-openai-key
88
AI_COACH_MODEL=gpt-5.6-luna
9-
# none | low | medium | high | xhigh | max — only applies to reasoning models.
10-
AI_COACH_REASONING_EFFORT=max
9+
# Optional: none | low | medium | high | xhigh | max. Omit to use OpenAI's
10+
# documented medium default for GPT-5.6.
1111
# Two meters — keep in sync with TIERS in src/lib/site.ts and wrangler.jsonc.
1212
# *_AI_CALLS_PER_MONTH is the advertised allowance (explain / hooks / chat /
1313
# notebook summaries). *_AUTO_* covers what the extension fires on its own

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import {
44
coachCacheKey,
55
completionTuning,
66
DEFAULT_COACH_MODEL,
7-
DEFAULT_COACH_REASONING_EFFORT,
87
isReasoningModel,
98
normalizeCoachRequest,
109
normalizeReasoningEffort,
@@ -99,23 +98,23 @@ describe("runCoach", () => {
9998
await expect(runCoach("sk-test", "gpt-4.1-nano", baseReq)).rejects.toThrow("openai_empty");
10099
});
101100

102-
it("sends reasoning params (not temperature/max_tokens) for the luna default model", async () => {
101+
it("uses Luna's documented default effort without sending the rejected flag", async () => {
103102
const fetchMock = mockOpenAi(JSON.stringify({ meaning: "to see", nuance: "casual" }));
104103
vi.stubGlobal("fetch", fetchMock);
105104
await runCoach("sk-test", DEFAULT_COACH_MODEL, baseReq);
106105
const body = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
107106
expect(body.model).toBe("gpt-5.6-luna");
108-
expect(body.reasoning_effort).toBe("max");
107+
expect(body).not.toHaveProperty("reasoning_effort");
109108
expect(body.max_completion_tokens).toBeGreaterThan(400);
110109
expect(body).not.toHaveProperty("temperature");
111110
expect(body).not.toHaveProperty("max_tokens");
112111
});
113112
});
114113

115114
describe("reasoning model tuning", () => {
116-
it("defaults the coach to gpt-5.6-luna at max effort", () => {
115+
it("defaults the coach to gpt-5.6-luna and OpenAI's medium effort", () => {
117116
expect(DEFAULT_COACH_MODEL).toBe("gpt-5.6-luna");
118-
expect(DEFAULT_COACH_REASONING_EFFORT).toBe("max");
117+
expect(reasoningEffortForModel(DEFAULT_COACH_MODEL)).toBe("medium");
119118
});
120119

121120
it("classifies model families", () => {
@@ -133,14 +132,17 @@ describe("reasoning model tuning", () => {
133132
});
134133

135134
it("gives reasoning tokens headroom scaled by effort", () => {
135+
const omitted = completionTuning("gpt-5.6-luna", { temperature: 0.4, maxTokens: 400 });
136136
const none = completionTuning("gpt-5.6-luna", { temperature: 0.4, maxTokens: 400, effort: "none" });
137137
const low = completionTuning("gpt-5.6-luna", { temperature: 0.4, maxTokens: 400, effort: "low" });
138138
const max = completionTuning("gpt-5.6-luna", { temperature: 0.4, maxTokens: 400, effort: "max" });
139+
expect(omitted).toEqual({ max_completion_tokens: 4400 });
139140
expect(none.max_completion_tokens).toBe(400);
140141
expect(low.max_completion_tokens).toBe(2400);
141142
expect(max.max_completion_tokens).toBe(25400);
142143
expect(max.max_completion_tokens).toBeGreaterThanOrEqual(25_000);
143144
expect(max.reasoning_effort).toBe("max");
145+
expect(low.reasoning_effort).toBe("low");
144146
});
145147

146148
it("uses conservative defaults but preserves explicit model overrides", () => {

web/src/lib/ai-coach.ts

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -73,29 +73,24 @@ export type CoachResult = ExplainResult | HooksResult | ChatResult;
7373

7474
// Verified August 2026: gpt-5.6-luna (released 2026-07-09) is $0.20 / 1M input,
7575
// $1.20 / 1M output, $0.02 / 1M cached input. It is a reasoning model; at the
76-
// "max" effort we run the coach on, reasoning tokens are billed as output. The
77-
// actual cost is workload-dependent and can exceed the backend's $0.002/call
78-
// assumption, so usage must be monitored. Overridable via AI_COACH_MODEL /
79-
// AI_COACH_REASONING_EFFORT env.
76+
// reasoning tokens are billed as output. The actual cost is workload-dependent
77+
// and can exceed the backend's $0.002/call assumption, so usage must be
78+
// monitored. The model is overridable via AI_COACH_MODEL; reasoning effort is
79+
// optional and otherwise uses OpenAI's documented default.
8080
export const DEFAULT_COACH_MODEL = "gpt-5.6-luna";
8181

8282
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
8383

84-
// "Max mode": the highest reasoning effort Luna offers. The coach is a
85-
// user-facing quality surface (the whole reason we left gpt-4.1-nano), so it
86-
// defaults to max; background callers pass something cheaper.
87-
export const DEFAULT_COACH_REASONING_EFFORT: ReasoningEffort = "max";
88-
8984
const REASONING_EFFORTS: readonly ReasoningEffort[] = ["none", "minimal", "low", "medium", "high", "xhigh", "max"];
9085
const GPT_56_EFFORTS: readonly ReasoningEffort[] = ["none", "low", "medium", "high", "xhigh", "max"];
9186

9287
export function normalizeReasoningEffort(value: unknown): ReasoningEffort | null {
9388
return REASONING_EFFORTS.includes(value as ReasoningEffort) ? (value as ReasoningEffort) : null;
9489
}
9590

96-
/** GPT-5.6 is the only model for which this app chooses `max` by default.
97-
* Explicit operator overrides are preserved: silently changing `none`, `xhigh`,
98-
* or `max` changes latency, cost, and quality, and model capabilities evolve. */
91+
/** Resolve the effective effort used for token-budget headroom. OpenAI documents
92+
* `medium` as GPT-5.6's default when the request omits `reasoning_effort`.
93+
* Explicit operator overrides are preserved after model validation. */
9994
export function reasoningEffortForModel(model: string, requested?: ReasoningEffort): ReasoningEffort {
10095
if (requested && isReasoningModel(model)) {
10196
const supported = supportedReasoningEfforts(model);
@@ -104,7 +99,7 @@ export function reasoningEffortForModel(model: string, requested?: ReasoningEffo
10499
}
105100
}
106101
if (requested) return requested;
107-
return /^gpt-5\.6(?:-|$)/.test(model) ? DEFAULT_COACH_REASONING_EFFORT : "medium";
102+
return "medium";
108103
}
109104

110105
function supportedReasoningEfforts(model: string): readonly ReasoningEffort[] | null {
@@ -155,7 +150,7 @@ export function completionTuning(
155150
? 25_000
156151
: 16_000;
157152
return {
158-
reasoning_effort: effort,
153+
...(opts.effort ? { reasoning_effort: effort } : {}),
159154
max_completion_tokens: opts.maxTokens + headroom,
160155
};
161156
}

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ vi.mock("@opennextjs/cloudflare", () => ({
99
},
1010
}));
1111

12-
const { currentMonth, getUsage, incrementUsage, quotaFor, refundUsage, reserveUsage } =
12+
const { currentMonth, getCoachConfig, getUsage, incrementUsage, quotaFor, refundUsage, reserveUsage } =
1313
await import("./ai-store");
1414

1515
const MONTH = "2026-08";
@@ -73,6 +73,19 @@ describe("quotaFor", () => {
7373
});
7474
});
7575

76+
describe("getCoachConfig", () => {
77+
it("omits explicit reasoning effort by default", async () => {
78+
const config = await getCoachConfig();
79+
expect(config.model).toBe("gpt-5.6-luna");
80+
expect(config.reasoningEffort).toBeUndefined();
81+
});
82+
83+
it("preserves an explicit supported operator override", async () => {
84+
vi.stubEnv("AI_COACH_REASONING_EFFORT", "low");
85+
expect((await getCoachConfig()).reasoningEffort).toBe("low");
86+
});
87+
});
88+
7689
describe("reserveUsage", () => {
7790
it("claims a slot and reports the post-claim count", async () => {
7891
const user = "res-basic";

web/src/lib/ai-store.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export async function getOpenAiKey(): Promise<string | null> {
7070

7171
export async function getCoachConfig(): Promise<{
7272
model: string;
73-
reasoningEffort: ReasoningEffort;
73+
reasoningEffort?: ReasoningEffort;
7474
freeLimit: number;
7575
proLimit: number;
7676
maxLimit: number;
@@ -89,7 +89,9 @@ export async function getCoachConfig(): Promise<{
8989
};
9090
return {
9191
model,
92-
reasoningEffort: reasoningEffortForModel(model, configuredEffort ?? undefined),
92+
reasoningEffort: configuredEffort
93+
? reasoningEffortForModel(model, configuredEffort)
94+
: undefined,
9395
freeLimit: num(process.env.FREE_AI_CALLS_PER_MONTH || env.FREE_AI_CALLS_PER_MONTH, DEFAULT_FREE_LIMIT),
9496
proLimit: num(process.env.PRO_AI_CALLS_PER_MONTH || env.PRO_AI_CALLS_PER_MONTH, DEFAULT_PRO_LIMIT),
9597
maxLimit: num(process.env.MAX_AI_CALLS_PER_MONTH || env.MAX_AI_CALLS_PER_MONTH, DEFAULT_MAX_LIMIT),

web/wrangler.jsonc

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,13 @@
3030
],
3131
// AI coach (issue #13). OPENAI_API_KEY is a Worker secret, not a var:
3232
// npx wrangler secret put OPENAI_API_KEY
33-
// gpt-5.6-luna at max reasoning effort ($0.20/$1.20 per 1M tokens as of
34-
// Aug 2026; reasoning tokens bill as output). Actual cost varies by task and
35-
// must be monitored. Background auto calls (word extraction, anime context) run
36-
// the same model at low effort. Limits are per Clerk user per month,
33+
// gpt-5.6-luna ($0.20/$1.20 per 1M tokens as of Aug 2026; reasoning tokens
34+
// bill as output). Omitted reasoning effort uses OpenAI's documented medium
35+
// default. Background auto calls (word extraction, anime context) explicitly
36+
// use low effort. Limits are per Clerk user per month,
3737
// gated by plan (free/pro/max) from Clerk publicMetadata — see resolvePlan.
3838
"vars": {
3939
"AI_COACH_MODEL": "gpt-5.6-luna",
40-
"AI_COACH_REASONING_EFFORT": "max",
4140
// Two meters. *_AI_CALLS_PER_MONTH is what the pricing page advertises and
4241
// covers only AI the learner asks for (explain / hooks / chat / notebook
4342
// summaries). *_AUTO_AI_CALLS_PER_MONTH covers what the extension fires by

0 commit comments

Comments
 (0)