mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(ai): the provider status says whether the shared provider fits
GET /api/user/ai-provider gains three additive fields: the instance-wide health tri-state, whether this caller already holds a receipt, and the one boolean that answers 'may the shared provider be offered in one tap'. The offer is the conjunction of five preconditions and every one of them can only take it away: the operator's provider is the one that would serve the caller, the health is demonstrably healthy, the assistant master and coach flags are on, the acting record holds its own credentials, and no receipt is on file yet. A managed profile is excluded by the positive form of the policy test - 'not deny' would have let a child's record through. Nothing here probes a provider; a probe would bill the operator for every registration on the instance. docs/api/openapi.yaml is regenerated from the schema source.
This commit is contained in:
1 parent
82b7c11b05
commit
0d2bad69e3
4 files changed
+261
No files matched your search
@@ -44485,6 +44485,30 @@ components:
|
||||
- type: "null"
|
||||
description: v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in
|
||||
comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.
|
||||
serverProviderHealth:
|
||||
type: string
|
||||
enum:
|
||||
- healthy
|
||||
- unhealthy
|
||||
- unknown
|
||||
description: "Instance-wide health of the operator's shared providers (`admin-openai` / `admin-codex`), folded across
|
||||
all accounts and reduced to a tri-state: `healthy` when one of them demonstrably served somebody within the
|
||||
last 24 h and no credential sits in its auth-failure cooldown, `unhealthy` when they have been tried and are
|
||||
not working, `unknown` when nothing has been tried yet on this instance or the read failed. Deliberately
|
||||
carries no count, no timestamp and no account identity — 'the provider of this instance last worked' is a
|
||||
statement about the instance, not about other people. Fails closed: treat `unknown` exactly like
|
||||
`unhealthy`. Reading this never probes a provider."
|
||||
serverProviderOffer:
|
||||
type: boolean
|
||||
description: "Whether the shared provider may honestly be offered to this caller in one tap. True only when all of: the
|
||||
operator's provider is the one that would serve them (`managedBy: \"server\"`), `serverProviderHealth` is
|
||||
`healthy`, the operator's assistant master + coach flags are on, the acting record holds its own credentials
|
||||
(a managed profile never does), and the caller holds no receipt yet. Anything unknown makes it false."
|
||||
serverProviderConsent:
|
||||
type: boolean
|
||||
description: Whether this caller already holds an active `ai_full` / `ai_coach` receipt — the grant `POST
|
||||
/api/consent/ai/web` mints. When true there is nothing left to offer, and `serverProviderOffer` is false for
|
||||
that reason rather than for a bad one.
|
||||
required:
|
||||
- provider
|
||||
- model
|
||||
@@ -44500,6 +44524,9 @@ components:
|
||||
- compatModel
|
||||
- hasCompatKey
|
||||
- responseTimeoutSeconds
|
||||
- serverProviderHealth
|
||||
- serverProviderOffer
|
||||
- serverProviderConsent
|
||||
additionalProperties: false
|
||||
description: The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`).
|
||||
Reports key presence + a masked preview only — never a plaintext key.
|
||||
|
||||
@@ -43,11 +43,33 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
}));
|
||||
|
||||
// v1.38.19 (wave D) — the four preconditions the honest offer rides on.
|
||||
vi.mock("@/lib/ai/server-provider-health", () => ({
|
||||
readServerProviderHealth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/feature-flags", () => ({
|
||||
getAssistantFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sharing/provider-work-authority", () => ({
|
||||
providerWorkAuthorityForRecord: vi.fn(() => ({ origin: "owner" })),
|
||||
providerCredentialPolicy: vi.fn(() => "personal"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/consent-guard", () => ({
|
||||
hasActiveConsentForSurface: vi.fn(),
|
||||
}));
|
||||
|
||||
import { GET, PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { resolveProviderAvailability } from "@/lib/ai/provider";
|
||||
import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
|
||||
import { readServerProviderHealth } from "@/lib/ai/server-provider-health";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { providerCredentialPolicy } from "@/lib/sharing/provider-work-authority";
|
||||
import { hasActiveConsentForSurface } from "@/lib/ai/consent-guard";
|
||||
|
||||
function patchRequest(body: unknown): Request {
|
||||
return new Request("http://localhost/api/user/ai-provider", {
|
||||
@@ -70,6 +92,18 @@ interface AiProviderResponse {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
// The shape every GET needs before a case narrows it: an owning caller on
|
||||
// an instance whose assistant surfaces are on, who has not consented yet.
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("unknown");
|
||||
vi.mocked(getAssistantFlags).mockResolvedValue({
|
||||
enabled: true,
|
||||
coach: true,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
correlations: true,
|
||||
});
|
||||
vi.mocked(providerCredentialPolicy).mockReturnValue("personal");
|
||||
vi.mocked(hasActiveConsentForSurface).mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe("GET /api/user/ai-provider availability", () => {
|
||||
@@ -405,3 +439,128 @@ describe("GET /api/user/ai-provider — the gateway's fields (#470)", () => {
|
||||
expect(JSON.stringify(body)).not.toContain("enc-gateway");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.38.19 (wave D) — the honest shared-provider offer.
|
||||
*
|
||||
* The setup flow's last screen used to promise that insights "work for you
|
||||
* right away" whenever `managedBy` said `server`. That is a presence read:
|
||||
* it knows a key is configured, never that it works, and it says nothing
|
||||
* about the consent receipt the egress actually needs. These three fields
|
||||
* are what makes the promise checkable — and every one of the five
|
||||
* preconditions below can only ever take the offer AWAY.
|
||||
*/
|
||||
describe("GET /api/user/ai-provider — the shared-provider offer", () => {
|
||||
function serverManaged(): void {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
aiProvider: null,
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: null,
|
||||
managedProfileAt: null,
|
||||
} as never);
|
||||
vi.mocked(resolveProviderAvailability).mockResolvedValue({
|
||||
aiAvailable: true,
|
||||
managedBy: "server",
|
||||
});
|
||||
}
|
||||
|
||||
async function read(): Promise<{
|
||||
serverProviderHealth: string;
|
||||
serverProviderOffer: boolean;
|
||||
serverProviderConsent: boolean;
|
||||
}> {
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
serverProviderHealth: string;
|
||||
serverProviderOffer: boolean;
|
||||
serverProviderConsent: boolean;
|
||||
};
|
||||
};
|
||||
return body.data;
|
||||
}
|
||||
|
||||
it("offers the shared provider when it is demonstrably healthy and nothing forbids it", async () => {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
await expect(read()).resolves.toMatchObject({
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: true,
|
||||
serverProviderConsent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["unhealthy", "unknown"] as const)(
|
||||
"never offers on %s health — unknown is not a maybe",
|
||||
async (health) => {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue(health);
|
||||
await expect(read()).resolves.toMatchObject({
|
||||
serverProviderHealth: health,
|
||||
serverProviderOffer: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("never offers when the operator switched the assistant surfaces off", async () => {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
vi.mocked(getAssistantFlags).mockResolvedValue({
|
||||
enabled: true,
|
||||
coach: false,
|
||||
briefing: true,
|
||||
insightStatus: true,
|
||||
correlations: true,
|
||||
});
|
||||
await expect(read()).resolves.toMatchObject({
|
||||
serverProviderHealth: "healthy",
|
||||
serverProviderOffer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("never offers to a managed profile — a child's record does not consent for itself", async () => {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
vi.mocked(providerCredentialPolicy).mockReturnValue("operator-default");
|
||||
await expect(read()).resolves.toMatchObject({ serverProviderOffer: false });
|
||||
});
|
||||
|
||||
it("stops offering once the user holds a receipt, and says the consent is on file", async () => {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
vi.mocked(hasActiveConsentForSurface).mockResolvedValue(true);
|
||||
await expect(read()).resolves.toMatchObject({
|
||||
serverProviderOffer: false,
|
||||
serverProviderConsent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("never offers a provider the operator does not manage", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
aiProvider: "OPENAI",
|
||||
aiModel: null,
|
||||
aiBaseUrl: null,
|
||||
aiAnthropicKeyEncrypted: null,
|
||||
aiLocalKeyEncrypted: null,
|
||||
aiOpenaiKeyEncrypted: "enc-openai",
|
||||
managedProfileAt: null,
|
||||
} as never);
|
||||
vi.mocked(resolveProviderAvailability).mockResolvedValue({
|
||||
aiAvailable: true,
|
||||
managedBy: "user",
|
||||
});
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
await expect(read()).resolves.toMatchObject({ serverProviderOffer: false });
|
||||
});
|
||||
|
||||
it("reads the health without probing anything", async () => {
|
||||
serverManaged();
|
||||
vi.mocked(readServerProviderHealth).mockResolvedValue("healthy");
|
||||
await read();
|
||||
expect(vi.mocked(readServerProviderHealth)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(readServerProviderHealth)).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,13 @@ import { isPublicUrl } from "@/lib/validations/notifications";
|
||||
import { isLocalAiHostAllowed } from "@/lib/ai/local-host-allowlist";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { resolveProviderAvailability } from "@/lib/ai/provider";
|
||||
import { readServerProviderHealth } from "@/lib/ai/server-provider-health";
|
||||
import { hasActiveConsentForSurface } from "@/lib/ai/consent-guard";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import {
|
||||
providerCredentialPolicy,
|
||||
providerWorkAuthorityForRecord,
|
||||
} from "@/lib/sharing/provider-work-authority";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { aiProviderPatchSchema } from "@/lib/validations/ai-provider";
|
||||
|
||||
@@ -35,6 +42,9 @@ export const GET = apiHandler(async () => {
|
||||
aiCompatModel: true,
|
||||
// v1.22 (#89)
|
||||
aiResponseTimeoutSeconds: true,
|
||||
// v1.38.19 (wave D) — a managed profile is never the party that gives
|
||||
// consent for itself; the offer below has to know.
|
||||
managedProfileAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,6 +55,46 @@ export const GET = apiHandler(async () => {
|
||||
// `managedBy` reports the origin only; no admin keys/endpoints are leaked.
|
||||
const { aiAvailable, managedBy } = await resolveProviderAvailability(user.id);
|
||||
|
||||
// ── v1.38.19 (wave D) — the shared provider, honestly ────────────────
|
||||
// `managedBy: "server"` says the operator configured a key. It has never
|
||||
// said the key WORKS, and the setup flow spent three releases promising
|
||||
// that it did. It also says nothing about the consent receipt
|
||||
// `consent-guard.ts` demands before any PHI reaches `admin-openai` /
|
||||
// `admin-codex`, which is the decision a fresh account is actually
|
||||
// missing — the provider was already chosen for them by the default chain.
|
||||
//
|
||||
// So three additive fields, and every one of them can only ever take the
|
||||
// offer away:
|
||||
// serverProviderHealth — the instance-wide tri-state, fail closed.
|
||||
// serverProviderConsent — this user already holds a receipt.
|
||||
// serverProviderOffer — all five preconditions hold at once.
|
||||
//
|
||||
// Nothing here probes a provider. The flow's rule ("nothing in the flow
|
||||
// talks to an AI provider") holds: a probe would bill the OPERATOR for
|
||||
// every registration on the instance.
|
||||
const serverProviderHealth = await readServerProviderHealth();
|
||||
const serverProviderConsent = await hasActiveConsentForSurface(
|
||||
user.id,
|
||||
"coach",
|
||||
);
|
||||
const assistantFlags = await getAssistantFlags();
|
||||
// `personal`, not merely "not deny": a guardian's own record resolves to
|
||||
// `personal`, while a managed profile resolves to `operator-default` —
|
||||
// its provider is the operator's by definition, and a child's record does
|
||||
// not consent to its own PHI egress. Both must be excluded here, and only
|
||||
// the positive form excludes both.
|
||||
const credentialPolicy = providerCredentialPolicy(
|
||||
providerWorkAuthorityForRecord(user.id),
|
||||
u?.managedProfileAt ?? null,
|
||||
);
|
||||
const serverProviderOffer =
|
||||
managedBy === "server" &&
|
||||
serverProviderHealth === "healthy" &&
|
||||
assistantFlags.enabled &&
|
||||
assistantFlags.coach &&
|
||||
credentialPolicy === "personal" &&
|
||||
!serverProviderConsent;
|
||||
|
||||
return apiSuccess({
|
||||
provider: u?.aiProvider ?? null,
|
||||
model: u?.aiModel ?? null,
|
||||
@@ -67,6 +117,11 @@ export const GET = apiHandler(async () => {
|
||||
hasCompatKey: Boolean(u?.aiCompatKeyEncrypted),
|
||||
// v1.22 (#89) — per-user response timeout, in seconds (null = default).
|
||||
responseTimeoutSeconds: u?.aiResponseTimeoutSeconds ?? null,
|
||||
// v1.38.19 (wave D) — see the block above. A tri-state and two booleans;
|
||||
// no count, no timestamp and no other account is inferable from them.
|
||||
serverProviderHealth,
|
||||
serverProviderOffer,
|
||||
serverProviderConsent,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -559,6 +559,26 @@ const aiProviderResponse = z
|
||||
.describe(
|
||||
"v1.22 (#89) — per-user response timeout for AI generation, in seconds (10–600). Null = the built-in comprehensive-briefing default (~120 s). Mainly for slow local/self-hosted backends.",
|
||||
),
|
||||
// v1.38.19 (wave D) — the setup flow's last screen used to promise
|
||||
// that insights "work for you right away" as soon as `managedBy` read
|
||||
// `server`. That is a presence read; it never knew whether the key
|
||||
// worked, and it said nothing about the consent receipt the egress
|
||||
// actually needs. These three fields make the promise checkable.
|
||||
serverProviderHealth: z
|
||||
.enum(["healthy", "unhealthy", "unknown"])
|
||||
.describe(
|
||||
"Instance-wide health of the operator's shared providers (`admin-openai` / `admin-codex`), folded across all accounts and reduced to a tri-state: `healthy` when one of them demonstrably served somebody within the last 24 h and no credential sits in its auth-failure cooldown, `unhealthy` when they have been tried and are not working, `unknown` when nothing has been tried yet on this instance or the read failed. Deliberately carries no count, no timestamp and no account identity — 'the provider of this instance last worked' is a statement about the instance, not about other people. Fails closed: treat `unknown` exactly like `unhealthy`. Reading this never probes a provider.",
|
||||
),
|
||||
serverProviderOffer: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether the shared provider may honestly be offered to this caller in one tap. True only when all of: the operator's provider is the one that would serve them (`managedBy: \"server\"`), `serverProviderHealth` is `healthy`, the operator's assistant master + coach flags are on, the acting record holds its own credentials (a managed profile never does), and the caller holds no receipt yet. Anything unknown makes it false.",
|
||||
),
|
||||
serverProviderConsent: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Whether this caller already holds an active `ai_full` / `ai_coach` receipt — the grant `POST /api/consent/ai/web` mints. When true there is nothing left to offer, and `serverProviderOffer` is false for that reason rather than for a bad one.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "AiProviderResponse",
|
||||
|
||||
Reference in new issue
Block a user