mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(ai): resolve the reasoning level against the operator's controls
Add resolveReasoning, the one decision for how hard a call may think: the operator's switch, then the provider's ability, then the requested level (the person's Coach preference, or a background job's own level), then the operator's cap, then a medium ceiling when the operator pays. The answer names the layer that decided it. loadReasoningControls reads the two app_settings columns, memoised per request, read afresh in background runs, and fails closed to "off". loadCoachReasoningState resolves the published Coach state from the memoised provider presence; resolveJobReasoning resolves one background call and never fails it. A guard proves the switch and the cap over the whole input space and requires every call that carries reasoning to take it from the resolver.
This commit is contained in:
1 parent
6112cfc6d5
commit
74e68745c1
5 files changed
+906
-1
No files matched your search
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* v1.41 — the operator's reasoning switch and cap cannot be bypassed.
|
||||
*
|
||||
* Two halves.
|
||||
*
|
||||
* Behaviour: `resolveReasoning` is the one decision. With the switch off it
|
||||
* answers `off` for the Coach and every background job, whatever the person
|
||||
* chose, whoever pays and whatever the provider can do; with it on, no answer
|
||||
* is above the cap. Proven exhaustively over the whole input space.
|
||||
*
|
||||
* Structure: a call carries reasoning only through `CompletionParams.reasoning`.
|
||||
* Every production file outside the provider clients that puts that key on a
|
||||
* call must take its value from the resolver (`@/lib/ai/reasoning/resolve` or
|
||||
* `@/lib/ai/reasoning/controls`). A file that hand-built `{ effort: "high" }`
|
||||
* would skip the switch, and goes red here.
|
||||
*
|
||||
* Its limit: the structural half reads source text for the setter shapes in
|
||||
* use (`reasoning: { effort`, `{ reasoning }`, `reasoning: completionReasoning(`,
|
||||
* `reasoning: resolveJobReasoning(`). A value laundered through another name
|
||||
* slips it; the resolver import is still required of the file that sets it.
|
||||
*
|
||||
* Mutation check: replace `resolveJobReasoning(...)` in `status-provider.ts`
|
||||
* with `{ effort: "high", summaries: false }` and drop the import: red.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BACKGROUND_REASONING_JOBS,
|
||||
REASONING_LEVELS,
|
||||
REASONING_MAX_EFFORTS,
|
||||
} from "@/lib/ai/reasoning/levels";
|
||||
import {
|
||||
completionReasoning,
|
||||
resolveReasoning,
|
||||
type ReasoningSurface,
|
||||
} from "@/lib/ai/reasoning/resolve";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
|
||||
const SRC = join(process.cwd(), "src");
|
||||
|
||||
const SURFACES: ReasoningSurface[] = ["coach", ...BACKGROUND_REASONING_JOBS];
|
||||
|
||||
describe("the switch and the cap win, behaviourally", () => {
|
||||
const supports = [
|
||||
null,
|
||||
{
|
||||
effort: true,
|
||||
summaries: true,
|
||||
liveSummaries: true,
|
||||
stateRoundTrip: true,
|
||||
offIsReal: false,
|
||||
},
|
||||
];
|
||||
|
||||
it("off reaches no surface as anything but off", () => {
|
||||
let cases = 0;
|
||||
for (const surface of SURFACES)
|
||||
for (const userPref of REASONING_LEVELS)
|
||||
for (const costOwner of ["user", "operator"] as const)
|
||||
for (const support of supports) {
|
||||
const r = resolveReasoning({
|
||||
surface,
|
||||
userPref,
|
||||
admin: { enabled: false, maxEffort: "high" },
|
||||
costOwner,
|
||||
support,
|
||||
});
|
||||
expect(r.effort).toBe("off");
|
||||
expect(r.source).toBe("admin_off");
|
||||
const sent = completionReasoning(r, surface);
|
||||
// A job sends nothing; the Coach sends an explicit off.
|
||||
if (surface === "coach") {
|
||||
expect(sent).toEqual({ effort: "off", summaries: false });
|
||||
} else {
|
||||
expect(sent).toBeUndefined();
|
||||
}
|
||||
cases++;
|
||||
}
|
||||
expect(cases).toBeGreaterThanOrEqual(100);
|
||||
});
|
||||
|
||||
it("no surface ever resolves above the cap", () => {
|
||||
for (const surface of SURFACES)
|
||||
for (const userPref of REASONING_LEVELS)
|
||||
for (const maxEffort of REASONING_MAX_EFFORTS) {
|
||||
const r = resolveReasoning({
|
||||
surface,
|
||||
userPref,
|
||||
admin: { enabled: true, maxEffort },
|
||||
costOwner: "user",
|
||||
});
|
||||
expect(REASONING_LEVELS.indexOf(r.effort)).toBeLessThanOrEqual(
|
||||
REASONING_LEVELS.indexOf(maxEffort),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** The provider clients map an already-resolved value onto their wire. */
|
||||
const WIRE_LAYER = (rel: string) =>
|
||||
/^lib\/ai\/[a-z-]+-client\.ts$/.test(rel) ||
|
||||
rel === "lib/ai/provider-runner.ts" ||
|
||||
rel.startsWith("lib/ai/reasoning/");
|
||||
|
||||
const SETTERS = [
|
||||
/\breasoning\s*:\s*\{\s*effort\b/,
|
||||
/\{\s*reasoning\s*\}/,
|
||||
/\breasoning\s*:\s*(?:completionReasoning|resolveJobReasoning)\s*\(/,
|
||||
];
|
||||
|
||||
const RESOLVER_IMPORT =
|
||||
/from\s+["']@\/lib\/ai\/reasoning\/(?:resolve|controls)["']/;
|
||||
|
||||
function stripComments(source: string): string {
|
||||
return source
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/(^|[^:])\/\/.*$/gm, "$1");
|
||||
}
|
||||
|
||||
describe("every call that carries reasoning took it from the resolver", () => {
|
||||
const files = walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((p) => !p.startsWith("generated/"))
|
||||
.filter((p) => !p.includes("__tests__"))
|
||||
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
|
||||
// Client components bind a form field called `reasoning`, not a call.
|
||||
.filter((p) => !p.startsWith("components/"))
|
||||
.filter((p) => !WIRE_LAYER(p));
|
||||
const setters = files.filter((rel) => {
|
||||
const code = stripComments(readFileSync(join(SRC, rel), "utf8"));
|
||||
return SETTERS.some((re) => re.test(code));
|
||||
});
|
||||
|
||||
it("finds the setters (an empty set is a failure, not a pass)", () => {
|
||||
expect(setters.length).toBeGreaterThanOrEqual(2);
|
||||
expect(setters).toContain("lib/insights/status-provider.ts");
|
||||
expect(setters).toContain("lib/insights/briefing-provider.ts");
|
||||
});
|
||||
|
||||
it("each one imports the resolver", () => {
|
||||
expect(
|
||||
setters.filter(
|
||||
(rel) => !RESOLVER_IMPORT.test(readFileSync(join(SRC, rel), "utf8")),
|
||||
),
|
||||
"A call carries reasoning that did not come from resolveReasoning, so the operator's switch and cap do not reach it.",
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -183,7 +183,7 @@ function buildUserProvider(row: UserAIRow): AIProvider | null {
|
||||
* An unparseable value answers false and keeps the OpenAI path, which is the
|
||||
* behaviour that value had before this function existed.
|
||||
*/
|
||||
function isAnthropicBaseUrl(baseUrl: string): boolean {
|
||||
export function isAnthropicBaseUrl(baseUrl: string): boolean {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(baseUrl).hostname.toLowerCase();
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BACKGROUND_REASONING_JOBS,
|
||||
REASONING_LEVELS,
|
||||
REASONING_MAX_EFFORTS,
|
||||
} from "../levels";
|
||||
import {
|
||||
coachReasoningCeiling,
|
||||
completionReasoning,
|
||||
parseReasoningMaxEffort,
|
||||
reasoningSupportForChainEntry,
|
||||
resolveReasoning,
|
||||
type ReasoningSurface,
|
||||
} from "../resolve";
|
||||
import type { ReasoningSupport } from "../support";
|
||||
|
||||
const ON = { enabled: true, maxEffort: "high" } as const;
|
||||
const FULL: ReasoningSupport = {
|
||||
effort: true,
|
||||
summaries: true,
|
||||
liveSummaries: true,
|
||||
stateRoundTrip: true,
|
||||
offIsReal: true,
|
||||
};
|
||||
const NO_REASONING: ReasoningSupport = { ...FULL, effort: false };
|
||||
const SURFACES: ReasoningSurface[] = ["coach", ...BACKGROUND_REASONING_JOBS];
|
||||
const rank = (l: string) => (REASONING_LEVELS as readonly string[]).indexOf(l);
|
||||
|
||||
describe("resolveReasoning — the Coach", () => {
|
||||
it("runs the person's level when nothing caps it", () => {
|
||||
for (const pref of REASONING_LEVELS) {
|
||||
expect(
|
||||
resolveReasoning({
|
||||
surface: "coach",
|
||||
userPref: pref,
|
||||
admin: ON,
|
||||
costOwner: "user",
|
||||
support: FULL,
|
||||
}),
|
||||
).toEqual({ effort: pref, summaries: pref !== "off", source: "user" });
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults a person who never chose to medium", () => {
|
||||
const r = resolveReasoning({
|
||||
surface: "coach",
|
||||
admin: ON,
|
||||
costOwner: "user",
|
||||
});
|
||||
expect(r).toEqual({ effort: "medium", summaries: true, source: "user" });
|
||||
});
|
||||
|
||||
it("clamps the person's level to the operator's cap", () => {
|
||||
const r = resolveReasoning({
|
||||
surface: "coach",
|
||||
userPref: "high",
|
||||
admin: { enabled: true, maxEffort: "low" },
|
||||
costOwner: "user",
|
||||
support: FULL,
|
||||
});
|
||||
expect(r).toEqual({ effort: "low", summaries: true, source: "admin_cap" });
|
||||
});
|
||||
|
||||
it("holds an operator-funded turn to medium", () => {
|
||||
const r = resolveReasoning({
|
||||
surface: "coach",
|
||||
userPref: "high",
|
||||
admin: ON,
|
||||
costOwner: "operator",
|
||||
support: FULL,
|
||||
});
|
||||
expect(r).toEqual({
|
||||
effort: "medium",
|
||||
summaries: true,
|
||||
source: "cost_cap",
|
||||
});
|
||||
});
|
||||
|
||||
it("names the operator's cap when it is the lower of the two", () => {
|
||||
const r = resolveReasoning({
|
||||
surface: "coach",
|
||||
userPref: "high",
|
||||
admin: { enabled: true, maxEffort: "low" },
|
||||
costOwner: "operator",
|
||||
});
|
||||
expect(r.source).toBe("admin_cap");
|
||||
expect(r.effort).toBe("low");
|
||||
});
|
||||
|
||||
it("asks for no summaries where the provider gives none", () => {
|
||||
const r = resolveReasoning({
|
||||
surface: "coach",
|
||||
userPref: "medium",
|
||||
admin: ON,
|
||||
costOwner: "user",
|
||||
support: { ...FULL, summaries: false },
|
||||
});
|
||||
expect(r.summaries).toBe(false);
|
||||
});
|
||||
|
||||
it("is off for a provider that cannot reason", () => {
|
||||
const r = resolveReasoning({
|
||||
surface: "coach",
|
||||
userPref: "high",
|
||||
admin: ON,
|
||||
costOwner: "user",
|
||||
support: NO_REASONING,
|
||||
});
|
||||
expect(r).toEqual({
|
||||
effort: "off",
|
||||
summaries: false,
|
||||
source: "unsupported",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveReasoning — background jobs", () => {
|
||||
it("never reads the Coach preference", () => {
|
||||
const r = resolveReasoning({
|
||||
surface: "daily_briefing",
|
||||
userPref: "off",
|
||||
admin: ON,
|
||||
costOwner: "user",
|
||||
});
|
||||
expect(r).toEqual({ effort: "medium", summaries: false, source: "job" });
|
||||
});
|
||||
|
||||
it("takes the operator level when the operator pays", () => {
|
||||
expect(
|
||||
resolveReasoning({
|
||||
surface: "daily_briefing",
|
||||
admin: ON,
|
||||
costOwner: "operator",
|
||||
}).effort,
|
||||
).toBe("low");
|
||||
expect(
|
||||
resolveReasoning({
|
||||
surface: "period_narrative_month",
|
||||
admin: ON,
|
||||
costOwner: "user",
|
||||
}).effort,
|
||||
).toBe("medium");
|
||||
expect(
|
||||
resolveReasoning({
|
||||
surface: "period_narrative_week",
|
||||
admin: ON,
|
||||
costOwner: "user",
|
||||
}).effort,
|
||||
).toBe("low");
|
||||
});
|
||||
|
||||
it("never asks a job for summaries", () => {
|
||||
for (const job of BACKGROUND_REASONING_JOBS) {
|
||||
expect(
|
||||
resolveReasoning({
|
||||
surface: job,
|
||||
admin: ON,
|
||||
costOwner: "user",
|
||||
support: FULL,
|
||||
}).summaries,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the operator's switch and cap win everywhere", () => {
|
||||
const owners = ["user", "operator"] as const;
|
||||
const supports = [null, FULL, NO_REASONING];
|
||||
const prefs = [undefined, ...REASONING_LEVELS];
|
||||
|
||||
it("off is off on every surface, for every preference, payer and provider", () => {
|
||||
let cases = 0;
|
||||
for (const surface of SURFACES)
|
||||
for (const userPref of prefs)
|
||||
for (const costOwner of owners)
|
||||
for (const support of supports)
|
||||
for (const maxEffort of REASONING_MAX_EFFORTS) {
|
||||
const r = resolveReasoning({
|
||||
surface,
|
||||
userPref,
|
||||
admin: { enabled: false, maxEffort },
|
||||
costOwner,
|
||||
support,
|
||||
});
|
||||
expect(r).toEqual({
|
||||
effort: "off",
|
||||
summaries: false,
|
||||
source: "admin_off",
|
||||
});
|
||||
cases++;
|
||||
}
|
||||
expect(cases).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
it("no resolution ever exceeds the operator's cap", () => {
|
||||
for (const surface of SURFACES)
|
||||
for (const userPref of prefs)
|
||||
for (const costOwner of owners)
|
||||
for (const maxEffort of REASONING_MAX_EFFORTS) {
|
||||
const r = resolveReasoning({
|
||||
surface,
|
||||
userPref,
|
||||
admin: { enabled: true, maxEffort },
|
||||
costOwner,
|
||||
support: FULL,
|
||||
});
|
||||
expect(rank(r.effort)).toBeLessThanOrEqual(rank(maxEffort));
|
||||
if (costOwner === "operator") {
|
||||
expect(rank(r.effort)).toBeLessThanOrEqual(rank("medium"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("a job resolved off under the switch sends nothing at all", () => {
|
||||
for (const job of BACKGROUND_REASONING_JOBS) {
|
||||
const r = resolveReasoning({
|
||||
surface: job,
|
||||
admin: { enabled: false, maxEffort: "high" },
|
||||
costOwner: "user",
|
||||
});
|
||||
expect(completionReasoning(r, job)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("completionReasoning", () => {
|
||||
it("sends the Coach's off explicitly, so a provider that can switch off does", () => {
|
||||
const r = resolveReasoning({
|
||||
surface: "coach",
|
||||
userPref: "off",
|
||||
admin: ON,
|
||||
costOwner: "user",
|
||||
});
|
||||
expect(completionReasoning(r, "coach")).toEqual({
|
||||
effort: "off",
|
||||
summaries: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends nothing for a provider that cannot reason", () => {
|
||||
const r = resolveReasoning({
|
||||
surface: "coach",
|
||||
userPref: "high",
|
||||
admin: ON,
|
||||
costOwner: "user",
|
||||
support: NO_REASONING,
|
||||
});
|
||||
expect(completionReasoning(r, "coach")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries a job's level without summaries", () => {
|
||||
const r = resolveReasoning({
|
||||
surface: "daily_briefing",
|
||||
admin: ON,
|
||||
costOwner: "user",
|
||||
});
|
||||
expect(completionReasoning(r, "daily_briefing")).toEqual({
|
||||
effort: "medium",
|
||||
summaries: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("coachReasoningCeiling", () => {
|
||||
it("is off when switched off, the cap otherwise, medium at most for the operator", () => {
|
||||
expect(
|
||||
coachReasoningCeiling({ enabled: false, maxEffort: "high" }, "user"),
|
||||
).toBe("off");
|
||||
expect(
|
||||
coachReasoningCeiling({ enabled: true, maxEffort: "high" }, "user"),
|
||||
).toBe("high");
|
||||
expect(
|
||||
coachReasoningCeiling({ enabled: true, maxEffort: "high" }, "operator"),
|
||||
).toBe("medium");
|
||||
expect(
|
||||
coachReasoningCeiling({ enabled: true, maxEffort: "low" }, "operator"),
|
||||
).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseReasoningMaxEffort", () => {
|
||||
it("keeps the three caps and reads anything else as no cap", () => {
|
||||
expect(parseReasoningMaxEffort("low")).toBe("low");
|
||||
expect(parseReasoningMaxEffort("medium")).toBe("medium");
|
||||
expect(parseReasoningMaxEffort("off")).toBe("high");
|
||||
expect(parseReasoningMaxEffort(null)).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reasoningSupportForChainEntry", () => {
|
||||
it("reads the client each chain tag builds", () => {
|
||||
expect(reasoningSupportForChainEntry("codex", {}).effort).toBe(true);
|
||||
expect(reasoningSupportForChainEntry("admin-codex", {}).effort).toBe(true);
|
||||
// The OpenAI client's default model does not reason.
|
||||
expect(reasoningSupportForChainEntry("openai", {}).effort).toBe(false);
|
||||
expect(
|
||||
reasoningSupportForChainEntry("openai", { model: "gpt-5" }).offIsReal,
|
||||
).toBe(false);
|
||||
expect(
|
||||
reasoningSupportForChainEntry("anthropic", { model: "claude-opus-5" })
|
||||
.offIsReal,
|
||||
).toBe(false);
|
||||
expect(
|
||||
reasoningSupportForChainEntry("admin-openai", {
|
||||
adminIsAnthropic: true,
|
||||
adminModel: "claude-opus-5",
|
||||
}).offIsReal,
|
||||
).toBe(false);
|
||||
expect(
|
||||
reasoningSupportForChainEntry("openai-compatible", {
|
||||
compatModel: "x",
|
||||
compatBaseUrl: "https://openrouter.ai/api/v1",
|
||||
}).stateRoundTrip,
|
||||
).toBe(true);
|
||||
expect(reasoningSupportForChainEntry("local", {}).effort).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* v1.41 — the reads behind a reasoning decision: the operator's two controls,
|
||||
* and the resolved Coach state the account payload publishes.
|
||||
*
|
||||
* Server-only. The decision itself is the pure `resolveReasoning` in
|
||||
* `./resolve.ts`; this file only gathers its inputs.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { memoizePerRequest } from "@/lib/request-cache";
|
||||
import { isAnthropicBaseUrl, probeProviderChain } from "@/lib/ai/provider";
|
||||
import { isOperatorFundedProvider } from "@/lib/ai/coach/budget";
|
||||
import type { ProviderChainType } from "@/lib/ai/provider-chain";
|
||||
import type { ProviderWorkAuthority } from "@/lib/sharing/provider-work-authority";
|
||||
import type { CompletionParams } from "@/lib/ai/types";
|
||||
import {
|
||||
coachReasoningLevel,
|
||||
type CoachPrefs,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
|
||||
import type { BackgroundReasoningJob } from "./levels";
|
||||
import {
|
||||
coachReasoningCeiling,
|
||||
completionReasoning,
|
||||
parseReasoningMaxEffort,
|
||||
reasoningSupportForChainEntry,
|
||||
resolveReasoning,
|
||||
type CoachReasoningState,
|
||||
type ReasoningAdminControls,
|
||||
type ReasoningCostOwner,
|
||||
} from "./resolve";
|
||||
|
||||
/**
|
||||
* Fails closed: an unreadable settings row reads as "reasoning off", so a
|
||||
* storage blip can never hand out reasoning the operator switched off. The
|
||||
* calls it affects still run, without reasoning.
|
||||
*/
|
||||
const UNREADABLE: ReasoningAdminControls = Object.freeze({
|
||||
enabled: false,
|
||||
maxEffort: "low",
|
||||
});
|
||||
|
||||
/**
|
||||
* The operator's reasoning switch and cap. Memoised per request; read afresh
|
||||
* inside a background run, so switching reasoning off at 02:05 stops the
|
||||
* nightly pass that began at 02:00 for every account after the switch.
|
||||
*/
|
||||
export function loadReasoningControls(): Promise<ReasoningAdminControls> {
|
||||
return memoizePerRequest(
|
||||
"ai-reasoning-controls",
|
||||
async () => {
|
||||
try {
|
||||
const row = await prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { aiReasoningEnabled: true, aiReasoningMaxEffort: true },
|
||||
});
|
||||
return {
|
||||
enabled: row?.aiReasoningEnabled ?? true,
|
||||
maxEffort: parseReasoningMaxEffort(row?.aiReasoningMaxEffort),
|
||||
};
|
||||
} catch {
|
||||
getEvent()?.addWarning(
|
||||
"Failed to load the reasoning controls; reasoning is off for this request",
|
||||
);
|
||||
return UNREADABLE;
|
||||
}
|
||||
},
|
||||
{ freshInBackground: true },
|
||||
);
|
||||
}
|
||||
|
||||
/** Who pays for a chain whose primary is `providerType` (`null`: none). */
|
||||
function costOwnerOf(providerType: string | undefined): ReasoningCostOwner {
|
||||
return providerType === undefined ||
|
||||
isOperatorFundedProvider(providerType as ProviderChainType)
|
||||
? "operator"
|
||||
: "user";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Coach reasoning state for the account payload. The preference is
|
||||
* the caller's own (Coach preferences belong to the person); the provider is
|
||||
* the record's, read through the memoised presence probe the `ai` block
|
||||
* already ran, plus one point read of the model columns.
|
||||
*/
|
||||
export async function loadCoachReasoningState(args: {
|
||||
prefs: CoachPrefs;
|
||||
recordId: string;
|
||||
authority: ProviderWorkAuthority | null;
|
||||
}): Promise<CoachReasoningState> {
|
||||
const [admin, presence] = await Promise.all([
|
||||
loadReasoningControls(),
|
||||
probeProviderChain(args.recordId, args.authority),
|
||||
]);
|
||||
const primary = presence.entries[0]?.providerType as
|
||||
ProviderChainType | undefined;
|
||||
const costOwner = costOwnerOf(primary);
|
||||
|
||||
let support = null;
|
||||
if (primary !== undefined) {
|
||||
const [row, settings] = await Promise.all([
|
||||
prisma.user.findUnique({
|
||||
where: { id: args.recordId },
|
||||
select: { aiModel: true, aiCompatModel: true, aiCompatBaseUrl: true },
|
||||
}),
|
||||
primary === "admin-openai"
|
||||
? prisma.appSettings.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: { adminAiModel: true, adminAiBaseUrl: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
support = reasoningSupportForChainEntry(primary, {
|
||||
model: row?.aiModel,
|
||||
compatModel: row?.aiCompatModel,
|
||||
compatBaseUrl: row?.aiCompatBaseUrl,
|
||||
adminModel: settings?.adminAiModel,
|
||||
adminIsAnthropic: settings?.adminAiBaseUrl
|
||||
? isAnthropicBaseUrl(settings.adminAiBaseUrl)
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
const preference = coachReasoningLevel(args.prefs);
|
||||
const resolved = resolveReasoning({
|
||||
surface: "coach",
|
||||
userPref: preference,
|
||||
admin,
|
||||
costOwner,
|
||||
support,
|
||||
});
|
||||
return {
|
||||
level: resolved.effort,
|
||||
preference,
|
||||
maxLevel: coachReasoningCeiling(admin, costOwner),
|
||||
available: admin.enabled && support !== null && support.effort,
|
||||
offIsReal: support?.offIsReal ?? true,
|
||||
source: resolved.source,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The reasoning one background call carries, or `undefined` for none.
|
||||
*
|
||||
* Only the jobs in `BACKGROUND_REASONING_JOBS` can ask; the operator's switch
|
||||
* and cap apply like everywhere else, the payer comes from the chain's
|
||||
* primary, and the provider's ability is left to the client (the model is not
|
||||
* known here, and every client sends nothing it cannot honour and learns a
|
||||
* refusal per endpoint). The decision is stamped on the wide event as meta,
|
||||
* never as an action: the job owns the action name.
|
||||
*/
|
||||
export async function resolveJobReasoning(
|
||||
job: BackgroundReasoningJob,
|
||||
chain: ReadonlyArray<{ providerType: ProviderChainType }>,
|
||||
): Promise<CompletionParams["reasoning"]> {
|
||||
// Reasoning is an improvement to a job, never a precondition for it: any
|
||||
// failure here sends the call exactly as it went before reasoning existed.
|
||||
let resolved;
|
||||
try {
|
||||
resolved = resolveReasoning({
|
||||
surface: job,
|
||||
admin: await loadReasoningControls(),
|
||||
costOwner: costOwnerOf(chain[0]?.providerType),
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
annotate({
|
||||
meta: {
|
||||
ai_reasoning_job: job,
|
||||
ai_reasoning_effort: resolved.effort,
|
||||
ai_reasoning_source: resolved.source,
|
||||
},
|
||||
});
|
||||
return completionReasoning(resolved, job);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* v1.41 — how hard one call may think, decided once per Coach turn or job.
|
||||
*
|
||||
* ## The one entry point
|
||||
*
|
||||
* resolveReasoning({ surface, userPref, admin, costOwner, support })
|
||||
* → { effort, summaries, source }
|
||||
*
|
||||
* - `surface`: `"coach"` or one of the background jobs in
|
||||
* `BACKGROUND_REASONING_JOBS`. Nothing else is representable, which is
|
||||
* the point: a status card, a batch note, an archetype, a nudge or a
|
||||
* document read has no surface to resolve against, so it cannot ask for
|
||||
* reasoning by accident.
|
||||
* - `userPref`: the person's Coach preference (`coachReasoningLevel(prefs)`).
|
||||
* Read for the Coach only; a job's level comes from
|
||||
* `BACKGROUND_REASONING_LEVEL`, never from the Coach preference.
|
||||
* - `admin`: the operator's two controls, `loadReasoningControls()` in
|
||||
* `./controls.ts` (one memoised read of the settings row).
|
||||
* - `costOwner`: who pays the call, `resolveCostOwner(chain)` from the Coach
|
||||
* budget module. An operator-funded chain is held to `medium` at most.
|
||||
* - `support`: what the chain's primary can do (`reasoningSupport(...)`), or
|
||||
* `null` when the caller does not know the model; unknown is optimistic,
|
||||
* because the clients learn a refusal per endpoint (`dialect-cache.ts`).
|
||||
*
|
||||
* The order is fixed and every arm wins over the ones after it: the
|
||||
* operator's switch, the provider's ability, the requested level, the
|
||||
* operator's cap, the cost cap. `source` names the arm that decided, so a
|
||||
* dashboard and the settings both say why a level is what it is.
|
||||
*
|
||||
* `completionReasoning(resolved, surface)` turns the answer into the
|
||||
* `CompletionParams.reasoning` a call carries, or `undefined` when the call
|
||||
* should go out exactly as it did before reasoning existed. Pass the answer
|
||||
* budget as `maxTokens`; each client adds the thinking budget itself.
|
||||
*
|
||||
* A leaf on purpose: no Prisma, no provider client, no `provider-chain.ts`
|
||||
* value import, so a worker, a route and a test can all call it. The loaders
|
||||
* live in `./controls.ts`.
|
||||
*/
|
||||
import type { ProviderChainType } from "../provider-chain";
|
||||
import type { CompletionParams } from "../types";
|
||||
import {
|
||||
BACKGROUND_REASONING_LEVEL,
|
||||
DEFAULT_REASONING_LEVEL,
|
||||
DEFAULT_REASONING_MAX_EFFORT,
|
||||
REASONING_LEVELS,
|
||||
REASONING_MAX_EFFORTS,
|
||||
capReasoningLevel,
|
||||
type BackgroundReasoningJob,
|
||||
type ReasoningLevel,
|
||||
type ReasoningMaxEffort,
|
||||
} from "./levels";
|
||||
import { reasoningSupport, type ReasoningSupport } from "./support";
|
||||
|
||||
/** Where a resolution is asked for: the Coach, or one admitted job. */
|
||||
export type ReasoningSurface = "coach" | BackgroundReasoningJob;
|
||||
|
||||
/** The operator's two controls on the settings row (migration 0376). */
|
||||
export interface ReasoningAdminControls {
|
||||
enabled: boolean;
|
||||
maxEffort: ReasoningMaxEffort;
|
||||
}
|
||||
|
||||
/** The controls an instance that never touched them has. */
|
||||
export const DEFAULT_REASONING_ADMIN_CONTROLS: ReasoningAdminControls =
|
||||
Object.freeze({
|
||||
enabled: true,
|
||||
maxEffort: DEFAULT_REASONING_MAX_EFFORT,
|
||||
});
|
||||
|
||||
/**
|
||||
* Why the level is what it is:
|
||||
*
|
||||
* - `user`: the person's Coach preference, unchanged.
|
||||
* - `job`: the job's own level from the background table, unchanged.
|
||||
* - `admin_off`: the operator switched reasoning off for the instance.
|
||||
* - `admin_cap`: lowered to the operator's highest level.
|
||||
* - `cost_cap`: lowered because the operator pays for the call.
|
||||
* - `unsupported`: the provider cannot reason at all.
|
||||
*/
|
||||
export const REASONING_SOURCES = [
|
||||
"user",
|
||||
"job",
|
||||
"admin_off",
|
||||
"admin_cap",
|
||||
"cost_cap",
|
||||
"unsupported",
|
||||
] as const;
|
||||
|
||||
export type ReasoningSource = (typeof REASONING_SOURCES)[number];
|
||||
|
||||
export interface ResolvedReasoning {
|
||||
effort: ReasoningLevel;
|
||||
/** Ask the provider for readable summaries (the Coach's live trail only). */
|
||||
summaries: boolean;
|
||||
source: ReasoningSource;
|
||||
}
|
||||
|
||||
export type ReasoningCostOwner = "operator" | "user";
|
||||
|
||||
export interface ResolveReasoningInput {
|
||||
surface: ReasoningSurface;
|
||||
userPref?: ReasoningLevel | null;
|
||||
admin: ReasoningAdminControls;
|
||||
costOwner: ReasoningCostOwner;
|
||||
support?: ReasoningSupport | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Coach reasoning block on `GET /api/auth/me`, resolved on the server
|
||||
* (`loadCoachReasoningState`) and read by the web settings and the native
|
||||
* client alike. Client-safe, which is why it lives here.
|
||||
*/
|
||||
export interface CoachReasoningState {
|
||||
/** The level a Coach turn runs at now, every cap applied. */
|
||||
level: ReasoningLevel;
|
||||
/** What the person picked (`coachPrefsJson.reasoning`), `medium` when never. */
|
||||
preference: ReasoningLevel;
|
||||
/** The highest level the settings may offer; `off` when the operator switched it off. */
|
||||
maxLevel: ReasoningLevel;
|
||||
/** False when the operator switched reasoning off or the provider cannot reason. */
|
||||
available: boolean;
|
||||
/** False when the provider cannot switch reasoning off (the lowest option reads "Minimal"). */
|
||||
offIsReal: boolean;
|
||||
/** Why `level` is what it is. */
|
||||
source: ReasoningSource;
|
||||
}
|
||||
|
||||
/** The highest level an operator-funded call may take (plan 7.2). */
|
||||
export const OPERATOR_FUNDED_REASONING_CAP: ReasoningMaxEffort = "medium";
|
||||
|
||||
/** Narrow an untrusted settings value onto the cap vocabulary. */
|
||||
export function parseReasoningMaxEffort(value: unknown): ReasoningMaxEffort {
|
||||
return typeof value === "string" &&
|
||||
(REASONING_MAX_EFFORTS as readonly string[]).includes(value)
|
||||
? (value as ReasoningMaxEffort)
|
||||
: DEFAULT_REASONING_MAX_EFFORT;
|
||||
}
|
||||
|
||||
function lower(a: ReasoningLevel, b: ReasoningLevel): boolean {
|
||||
return REASONING_LEVELS.indexOf(a) < REASONING_LEVELS.indexOf(b);
|
||||
}
|
||||
|
||||
export function resolveReasoning(
|
||||
input: ResolveReasoningInput,
|
||||
): ResolvedReasoning {
|
||||
const coach = input.surface === "coach";
|
||||
if (!input.admin.enabled) {
|
||||
return { effort: "off", summaries: false, source: "admin_off" };
|
||||
}
|
||||
if (input.support && !input.support.effort) {
|
||||
return { effort: "off", summaries: false, source: "unsupported" };
|
||||
}
|
||||
|
||||
let effort: ReasoningLevel;
|
||||
let source: ReasoningSource;
|
||||
if (input.surface === "coach") {
|
||||
effort = input.userPref ?? DEFAULT_REASONING_LEVEL;
|
||||
source = "user";
|
||||
} else {
|
||||
const levels = BACKGROUND_REASONING_LEVEL[input.surface];
|
||||
effort =
|
||||
input.costOwner === "operator" ? levels.operatorLevel : levels.level;
|
||||
source = "job";
|
||||
}
|
||||
|
||||
const adminCapped = capReasoningLevel(effort, input.admin.maxEffort);
|
||||
if (lower(adminCapped, effort)) {
|
||||
effort = adminCapped;
|
||||
source = "admin_cap";
|
||||
}
|
||||
if (input.costOwner === "operator") {
|
||||
const costCapped = capReasoningLevel(effort, OPERATOR_FUNDED_REASONING_CAP);
|
||||
if (lower(costCapped, effort)) {
|
||||
effort = costCapped;
|
||||
source = "cost_cap";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
effort,
|
||||
summaries: coach && effort !== "off" && (input.support?.summaries ?? true),
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `CompletionParams.reasoning` one call carries for `resolved`.
|
||||
*
|
||||
* `undefined` means "send what the call sent before reasoning existed": the
|
||||
* provider cannot reason, or a background job resolved to `off` (the
|
||||
* operator's switch included), which a job expresses by not asking. The Coach
|
||||
* sends an explicit `off` when the person or the operator chose it, so a
|
||||
* provider that can switch reasoning off really does (`offIsReal`).
|
||||
*/
|
||||
export function completionReasoning(
|
||||
resolved: ResolvedReasoning,
|
||||
surface: ReasoningSurface,
|
||||
): CompletionParams["reasoning"] {
|
||||
if (resolved.source === "unsupported") return undefined;
|
||||
if (resolved.effort === "off" && surface !== "coach") return undefined;
|
||||
return { effort: resolved.effort, summaries: resolved.summaries };
|
||||
}
|
||||
|
||||
/**
|
||||
* The highest level the Coach may take for this person right now: what the
|
||||
* settings dropdown offers. `off` when the operator switched reasoning off.
|
||||
*/
|
||||
export function coachReasoningCeiling(
|
||||
admin: ReasoningAdminControls,
|
||||
costOwner: ReasoningCostOwner,
|
||||
): ReasoningLevel {
|
||||
if (!admin.enabled) return "off";
|
||||
return costOwner === "operator"
|
||||
? capReasoningLevel(admin.maxEffort, OPERATOR_FUNDED_REASONING_CAP)
|
||||
: admin.maxEffort;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a chain entry can do with reasoning, from the configuration columns a
|
||||
* presence read already holds (no client built, no key decrypted). The chain
|
||||
* tag decides the client: the Codex paths are the Codex client, the person's
|
||||
* OpenAI key and the operator's slot are the OpenAI client (or Anthropic,
|
||||
* when the operator's base URL is Anthropic's), and so on. Model names fall
|
||||
* back to the defaults the resolvers use.
|
||||
*/
|
||||
export function reasoningSupportForChainEntry(
|
||||
providerType: ProviderChainType,
|
||||
config: {
|
||||
model?: string | null;
|
||||
compatModel?: string | null;
|
||||
compatBaseUrl?: string | null;
|
||||
adminModel?: string | null;
|
||||
adminIsAnthropic?: boolean;
|
||||
},
|
||||
): ReasoningSupport {
|
||||
switch (providerType) {
|
||||
case "codex":
|
||||
case "admin-codex":
|
||||
return reasoningSupport("codex", "");
|
||||
case "openai":
|
||||
return reasoningSupport("admin-key", config.model ?? "gpt-4o");
|
||||
case "anthropic":
|
||||
return reasoningSupport("anthropic", config.model ?? "claude-sonnet-4-6");
|
||||
case "local":
|
||||
return reasoningSupport("local", config.model ?? "");
|
||||
case "openai-compatible":
|
||||
return reasoningSupport(
|
||||
"openai-compatible",
|
||||
config.compatModel ?? config.model ?? "",
|
||||
config.compatBaseUrl ? { baseUrl: config.compatBaseUrl } : {},
|
||||
);
|
||||
case "admin-openai":
|
||||
return config.adminIsAnthropic
|
||||
? reasoningSupport(
|
||||
"anthropic",
|
||||
config.adminModel ?? "claude-sonnet-4-6",
|
||||
)
|
||||
: reasoningSupport("admin-key", config.adminModel ?? "gpt-4o");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user