diff --git a/src/lib/ai/__tests__/vision-capability.test.ts b/src/lib/ai/__tests__/vision-capability.test.ts index 9a1e03c24..ec2fbb395 100644 --- a/src/lib/ai/__tests__/vision-capability.test.ts +++ b/src/lib/ai/__tests__/vision-capability.test.ts @@ -39,7 +39,10 @@ describe("supportsVisionForConfig", () => { expect(supportsVisionForConfig(providerType, "gpt-4o-mini")).toBe(true); expect(supportsVisionForConfig(providerType, "gpt-4.1")).toBe(true); expect(supportsVisionForConfig(providerType, "gpt-4-turbo")).toBe(true); + // Full o-series reasoning models read images. expect(supportsVisionForConfig(providerType, "o1")).toBe(true); + expect(supportsVisionForConfig(providerType, "o3")).toBe(true); + expect(supportsVisionForConfig(providerType, "o4")).toBe(true); } }); @@ -49,6 +52,20 @@ describe("supportsVisionForConfig", () => { expect(supportsVisionForConfig("openai", null)).toBe(false); }); + it("rejects text-only o-series variants (-mini / -preview) for openai", () => { + // o1-mini / o1-preview / o3-mini accept the `o*` prefix but have no image + // input — claiming vision here 400s the user at the image block. + for (const providerType of [ + "openai", + "admin-openai", + "admin-key", + ] as const) { + expect(supportsVisionForConfig(providerType, "o1-mini")).toBe(false); + expect(supportsVisionForConfig(providerType, "o1-preview")).toBe(false); + expect(supportsVisionForConfig(providerType, "o3-mini")).toBe(false); + } + }); + it("trusts local (operator opted in) regardless of model string", () => { expect(supportsVisionForConfig("local", "llava")).toBe(true); expect(supportsVisionForConfig("local", null)).toBe(true); @@ -72,6 +89,12 @@ describe("supportsVisionForConfig", () => { // The `-codex` specialist slugs are text-only despite the gpt-5 prefix. expect(supportsVisionForConfig("codex", "gpt-5-codex")).toBe(false); expect(supportsVisionForConfig("codex", "gpt-5.1-codex-mini")).toBe(false); + // Text-only o-series variants are excluded for codex too. + expect(supportsVisionForConfig("codex", "o1-mini")).toBe(false); + expect(supportsVisionForConfig("codex", "o3-mini")).toBe(false); + expect(supportsVisionForConfig("codex", "o1-preview")).toBe(false); + // Full o-series stays vision-capable. + expect(supportsVisionForConfig("codex", "o3")).toBe(true); }); it("never reports vision for none", () => { diff --git a/src/lib/ai/vision-capability.ts b/src/lib/ai/vision-capability.ts index daf382900..baea4da03 100644 --- a/src/lib/ai/vision-capability.ts +++ b/src/lib/ai/vision-capability.ts @@ -41,18 +41,32 @@ function anthropicSupportsVision(model: string): boolean { /** * OpenAI vision models (also covers the operator's admin key + Codex when it * runs an OpenAI model — though Codex is excluded by `providerType`). `gpt-4o`, - * `gpt-4.1`, `gpt-4-turbo`, and the `o*` reasoning models read image content; - * `gpt-3.5*` and the legacy `gpt-4` (non-turbo) 400 on an image block. + * `gpt-4.1`, `gpt-4-turbo`, and the full `o1` / `o3` / `o4` reasoning models + * read image content; `gpt-3.5*` and the legacy `gpt-4` (non-turbo) 400 on an + * image block. + * + * The `o*` reasoning line has text-only variants that DO accept the `o*` prefix + * but reject an image block: `o1-mini` / `o1-preview` and `o3-mini` have no + * vision input (only the full `o1` / `o3` are multimodal). They must be + * excluded or a user pinned to one is told OCR works, then the provider 400s at + * the image block. We treat an `o1` / `o3` / `o4` slug as vision-capable only + * when it is NOT a `-mini` / `-preview` variant — the conservative default for + * any unverified narrow slug. */ +function isVisionReasoningSlug(m: string): boolean { + if (!(m.startsWith("o1") || m.startsWith("o3") || m.startsWith("o4"))) { + return false; + } + return !m.includes("-mini") && !m.includes("-preview"); +} + function openaiSupportsVision(model: string): boolean { const m = model.toLowerCase(); return ( m.startsWith("gpt-4o") || m.startsWith("gpt-4.1") || m.startsWith("gpt-4-turbo") || - m.startsWith("o1") || - m.startsWith("o3") || - m.startsWith("o4") + isVisionReasoningSlug(m) ); } @@ -78,9 +92,9 @@ function codexSupportsVision(model: string): boolean { m.startsWith("gpt-4o") || m.startsWith("gpt-4.1") || m.startsWith("gpt-4-turbo") || - m.startsWith("o1") || - m.startsWith("o3") || - m.startsWith("o4") + // Same `-mini` / `-preview` text-only exclusion as the OpenAI gate: a + // future codex slug rotation onto e.g. `o3-mini` must not claim vision. + isVisionReasoningSlug(m) ); } diff --git a/src/lib/illness/__tests__/correlation.test.ts b/src/lib/illness/__tests__/correlation.test.ts index e52963ed7..ce2032b3b 100644 --- a/src/lib/illness/__tests__/correlation.test.ts +++ b/src/lib/illness/__tests__/correlation.test.ts @@ -669,6 +669,70 @@ describe("computeIllnessCorrelation — red-flag escalation", () => { expect(fever?.days).toBe(3); expect(fever?.worstValue).toBe(39.0); }); + + it("does NOT escalate a sparse febrile series with calendar GAPS (cries-wolf guard)", () => { + // Symptom/vital points are present-days-only (never zero-filled). A user + // who logs a fever on 01-10/01-15/01-20/01-25 has FOUR isolated febrile + // days with multi-day gaps between them — NOT a sustained run. The run + // scan must compare calendar-day deltas, not array adjacency, or it falsely + // fires sustained_fever (which surfaces a "seek medical care" string AND an + // urgent cross-channel push). No two of these days are calendar-adjacent → + // the longest run is 1 → NO escalation. + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const out = computeIllnessCorrelation( + input({ + series: [ + { + type: rhr, + baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"), + episodeDays: flatBaseline(55, 5, "2026-01-15"), + }, + ], + dayLogFever: [ + { day: "2026-01-10", feverC: 39.2 }, + { day: "2026-01-15", feverC: 39.0 }, + { day: "2026-01-20", feverC: 38.8 }, + { day: "2026-01-25", feverC: 38.7 }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const fever = out.value.redFlags.find( + (f) => f.reason === "sustained_fever", + ); + expect(fever).toBeUndefined(); + }); + + it("DOES escalate a genuinely consecutive 3-calendar-day febrile run", () => { + // The mirror of the gap guard: three truly adjacent calendar days + // (01-10/01-11/01-12) all ≥ 38.5 → a real sustained fever → escalate. + const rhr: MeasurementType = "RESTING_HEART_RATE"; + const out = computeIllnessCorrelation( + input({ + series: [ + { + type: rhr, + baselineDays: jitteredBaseline(55, 1, 21, "2026-01-02"), + episodeDays: flatBaseline(55, 5, "2026-01-15"), + }, + ], + dayLogFever: [ + { day: "2026-01-10", feverC: 39.2 }, + { day: "2026-01-11", feverC: 38.9 }, + { day: "2026-01-12", feverC: 38.6 }, + ], + }), + ); + expect(out.status).toBe("ok"); + if (out.status !== "ok") return; + const fever = out.value.redFlags.find( + (f) => f.reason === "sustained_fever", + ); + expect(fever).toBeDefined(); + expect(fever?.days).toBe(3); + expect(fever?.worstValue).toBe(39.2); + }); }); describe("isAdverseDeviation", () => { diff --git a/src/lib/illness/correlation.ts b/src/lib/illness/correlation.ts index 112790c99..583726981 100644 --- a/src/lib/illness/correlation.ts +++ b/src/lib/illness/correlation.ts @@ -968,22 +968,37 @@ function runFlag( let bestWorst: number | null = null; let run = 0; let runWorst: number | null = null; + let prevDay: string | null = null; for (const p of sorted) { if (predicate(p.mean)) { - run++; - runWorst = - runWorst === null - ? p.mean - : worst === "min" - ? Math.min(runWorst, p.mean) - : Math.max(runWorst, p.mean); + // A run is CALENDAR-consecutive days. Symptom/vital points are + // present-days-only (never zero-filled), so a sparse febrile series + // (e.g. days 01/10/15/20) must NOT count as one long run — that fires a + // false sustained_fever escalation. Continue the run only when this + // point is the calendar day immediately after the previous matching one; + // otherwise the current point starts a fresh run of 1. + const adjacent = prevDay !== null && dayDiff(prevDay, p.day) === 1; + if (adjacent) { + run++; + runWorst = + runWorst === null + ? p.mean + : worst === "min" + ? Math.min(runWorst, p.mean) + : Math.max(runWorst, p.mean); + } else { + run = 1; + runWorst = p.mean; + } if (run > bestRun) { bestRun = run; bestWorst = runWorst; } + prevDay = p.day; } else { run = 0; runWorst = null; + prevDay = null; } } if (bestRun >= RED_FLAG_RUN_DAYS && bestWorst !== null) {