mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge pull request #449 from MBombeck/release/v1.28.5
release/v1.28.5 — subscription-AI document reading + surface cleanups
This commit is contained in:
19 files changed
+120
-99
No files matched your search
@@ -2,6 +2,15 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.28.5] — 2026-07-09 — Read documents with a subscription AI, cleaner surfaces
|
||||
|
||||
Documents can now be read by a signed-in (subscription) AI provider — the
|
||||
capability check no longer wrongly treats those models as unable to see images,
|
||||
so "read with AI" works on that path instead of erroring. Scanned PDFs and
|
||||
photos are read the same way. Two now-redundant blurbs are gone from the document
|
||||
panel, and on the recovery insights page each metric shows its explanation inside
|
||||
its own card instead of repeating the heading above it.
|
||||
|
||||
## [1.28.4] — 2026-07-09 — Medication efficacy
|
||||
|
||||
A per-medication "effect" view: see whether a medication is moving the outcome
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.28.4
|
||||
version: 1.28.5
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
@@ -171,20 +171,19 @@ test.describe("automatic AI reading — vault per-document contract", () => {
|
||||
|
||||
test.beforeEach(() => test.slow());
|
||||
|
||||
test("the vault shows the per-egress notice once and the explicit read action", async ({
|
||||
test("the vault shows the explicit read action for an external provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
// External egress (the codex/OAuth or any non-local provider case).
|
||||
// External egress (the codex/OAuth or any non-local provider case). The
|
||||
// per-document egress notice was retired — the settings-toggle honesty
|
||||
// confirm already covers the egress trade — so no per-document notice
|
||||
// renders here regardless of egress mode.
|
||||
await mockDocumentCapability(page, { egress: "external" });
|
||||
|
||||
await page.goto(`/documents?doc=${AI_PROBE_DOC_ID}`);
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
// The vendor-blind "leaves your machine" notice is shown exactly once.
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-ai-egress-notice"]'),
|
||||
).toHaveCount(1);
|
||||
// The per-document read is the explicit path — the action is present and the
|
||||
// user must tap it (auto-read never removes the affordance, only the tap).
|
||||
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
|
||||
@@ -218,8 +217,12 @@ test.describe("automatic AI reading — vault per-document contract", () => {
|
||||
expect(indexCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("a local-egress read shows no external notice", async ({ page }) => {
|
||||
// A self-hosted local model never leaves the machine — no egress notice.
|
||||
test("a local-egress provider shows the explicit read action", async ({
|
||||
page,
|
||||
}) => {
|
||||
// A self-hosted local model never leaves the machine; the read action is
|
||||
// offered the same way as for an external provider (no per-document notice
|
||||
// in either mode after the notice was retired).
|
||||
await mockDocumentCapability(page, {
|
||||
egress: "local",
|
||||
pdfSupported: false,
|
||||
@@ -229,8 +232,6 @@ test.describe("automatic AI reading — vault per-document contract", () => {
|
||||
const sheet = page.getByRole("dialog");
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
await expect(
|
||||
sheet.locator('[data-slot="document-ai-egress-notice"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(sheet.locator('[data-slot="document-read-ai"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -8261,9 +8261,7 @@
|
||||
"close": "Ausblenden"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "Dies sendet den Inhalt des Dokuments an deinen konfigurierten KI-Anbieter — es verlässt dieses Gerät zu einem KI-Dienst eines Drittanbieters. Ist der Anbieter ein persönliches Abonnement, gelten dessen eigene Datenschutz-Einstellungen, einschließlich Modelltraining, sofern du es dort nicht deaktiviert hast.",
|
||||
"blockTitle": "Mit KI auslesen",
|
||||
"available": "Lass die KI dieses Dokument lesen — Titel, Art und Datum vorschlagen, zusammenfassen oder den ganzen Text durchsuchbar machen.",
|
||||
"read": "Mit KI auslesen",
|
||||
"reread": "Erneut lesen",
|
||||
"reading": "Liest…",
|
||||
|
||||
@@ -8261,9 +8261,7 @@
|
||||
"close": "Hide"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "This sends the document's contents to your configured AI provider — it leaves this machine to a third-party AI service. If that provider is a personal subscription plan, its own data-use settings apply, including model training unless you have turned it off there.",
|
||||
"blockTitle": "Read with AI",
|
||||
"available": "Let the AI read this document — suggest a title, type and date, summarise it, or make its full text searchable.",
|
||||
"read": "Read with AI",
|
||||
"reread": "Read again",
|
||||
"reading": "Reading…",
|
||||
|
||||
@@ -8261,9 +8261,7 @@
|
||||
"close": "Ocultar"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "Esto envía el contenido del documento a tu proveedor de IA configurado — sale de esta máquina hacia un servicio de IA de terceros. Si ese proveedor es una suscripción personal, se aplican sus propios ajustes de uso de datos, incluido el entrenamiento del modelo salvo que lo hayas desactivado allí.",
|
||||
"blockTitle": "Leer con IA",
|
||||
"available": "Deja que la IA lea este documento: sugerir un título, tipo y fecha, resumirlo o hacer que todo su texto sea buscable.",
|
||||
"read": "Leer con IA",
|
||||
"reread": "Leer de nuevo",
|
||||
"reading": "Leyendo…",
|
||||
|
||||
@@ -8261,9 +8261,7 @@
|
||||
"close": "Masquer"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "Ceci envoie le contenu du document à votre fournisseur d'IA configuré — il quitte cette machine vers un service d'IA tiers. Si ce fournisseur est un abonnement personnel, ses propres paramètres d'utilisation des données s'appliquent, y compris l'entraînement du modèle sauf si vous l'avez désactivé là-bas.",
|
||||
"blockTitle": "Lire avec l’IA",
|
||||
"available": "Laissez l’IA lire ce document — suggérer un titre, un type et une date, le résumer ou rendre tout son texte consultable.",
|
||||
"read": "Lire avec l’IA",
|
||||
"reread": "Relire",
|
||||
"reading": "Lecture…",
|
||||
|
||||
@@ -8261,9 +8261,7 @@
|
||||
"close": "Nascondi"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "Questo invia il contenuto del documento al provider IA configurato — lascia questa macchina verso un servizio IA di terze parti. Se quel provider è un abbonamento personale, si applicano le sue impostazioni sull'uso dei dati, incluso l'addestramento del modello a meno che tu non l'abbia disattivato lì.",
|
||||
"blockTitle": "Leggi con l’IA",
|
||||
"available": "Lascia che l’IA legga questo documento: suggerire un titolo, un tipo e una data, riassumerlo o rendere ricercabile tutto il testo.",
|
||||
"read": "Leggi con l’IA",
|
||||
"reread": "Leggi di nuovo",
|
||||
"reading": "Lettura…",
|
||||
|
||||
@@ -8261,9 +8261,7 @@
|
||||
"close": "Ukryj"
|
||||
},
|
||||
"ai": {
|
||||
"egressNotice": "To wysyła treść dokumentu do skonfigurowanego dostawcy AI — opuszcza ona to urządzenie i trafia do zewnętrznej usługi AI. Jeśli ten dostawca to subskrypcja osobista, obowiązują jego własne ustawienia wykorzystania danych, w tym trenowanie modelu, o ile nie wyłączysz go tam.",
|
||||
"blockTitle": "Odczytaj z AI",
|
||||
"available": "Pozwól AI odczytać ten dokument — zaproponować tytuł, typ i datę, streścić go lub udostępnić cały tekst do wyszukiwania.",
|
||||
"read": "Odczytaj z AI",
|
||||
"reread": "Odczytaj ponownie",
|
||||
"reading": "Odczytywanie…",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.28.4",
|
||||
"version": "1.28.5",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.28.4";
|
||||
/* @sw-version-fallback */ "v1.28.5";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -34,7 +34,6 @@ function base(
|
||||
aiEnabled: true,
|
||||
indexEnabled: true,
|
||||
unavailableReason: null,
|
||||
egressExternal: false,
|
||||
actionsDisabled: false,
|
||||
onSuggest: noop,
|
||||
suggestPending: false,
|
||||
@@ -143,20 +142,14 @@ describe("<DocumentAiSection>", () => {
|
||||
expect(html).toContain("Not saved");
|
||||
});
|
||||
|
||||
it("shows the vendor-blind egress notice before an external document read", () => {
|
||||
const html = render(base({ aiEnabled: true, egressExternal: true }));
|
||||
expect(html).toContain('data-slot="document-ai-egress-notice"');
|
||||
expect(html).toContain("third-party AI service");
|
||||
// Vendor-blind: never names a specific AI vendor in the document surface.
|
||||
expect(html).not.toContain("OpenAI");
|
||||
expect(html).not.toContain("ChatGPT");
|
||||
// The actions are still offered — the notice informs, it does not block.
|
||||
expect(html).toContain('data-slot="assist-suggest"');
|
||||
});
|
||||
|
||||
it("omits the egress notice when the read stays on the local machine", () => {
|
||||
const html = render(base({ aiEnabled: true, egressExternal: false }));
|
||||
it("renders no per-document egress notice — the settings confirm covers it", () => {
|
||||
// The per-document egress notice was retired; the settings-toggle honesty
|
||||
// confirm is now the single place the egress trade is stated.
|
||||
const html = render(base({ aiEnabled: true }));
|
||||
expect(html).not.toContain('data-slot="document-ai-egress-notice"');
|
||||
// The actions are still offered.
|
||||
expect(html).toContain('data-slot="assist-suggest"');
|
||||
expect(html).toContain('data-slot="document-read-ai"');
|
||||
});
|
||||
|
||||
it("hides the Read-with-AI action when indexing is unavailable but keeps suggest", () => {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* status pill stays, honestly reflecting any local auto-index, and the manual
|
||||
* form below stays fully usable.
|
||||
*/
|
||||
import { FileText, ScanText, ShieldAlert, WandSparkles } from "lucide-react";
|
||||
import { FileText, ScanText, WandSparkles } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -47,7 +47,6 @@ export function DocumentAiSection({
|
||||
aiEnabled,
|
||||
indexEnabled,
|
||||
unavailableReason,
|
||||
egressExternal,
|
||||
actionsDisabled,
|
||||
onSuggest,
|
||||
suggestPending,
|
||||
@@ -75,12 +74,6 @@ export function DocumentAiSection({
|
||||
aiEnabled: boolean;
|
||||
indexEnabled: boolean;
|
||||
unavailableReason: "no-provider" | "enable-local-ocr" | null;
|
||||
/**
|
||||
* True when a document read will egress to a third-party AI service (any
|
||||
* provider other than a self-hosted local model). Drives the vendor-blind
|
||||
* "this leaves your machine" notice shown before the AI actions.
|
||||
*/
|
||||
egressExternal: boolean;
|
||||
/** Capability still resolving — the transport mode isn't known yet. */
|
||||
actionsDisabled: boolean;
|
||||
onSuggest: () => void;
|
||||
@@ -143,23 +136,8 @@ export function DocumentAiSection({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{egressExternal ? (
|
||||
<div
|
||||
data-slot="document-ai-egress-notice"
|
||||
role="note"
|
||||
className="border-border text-muted-foreground flex items-start gap-2 rounded-lg border border-dashed px-3 py-2.5 text-xs"
|
||||
>
|
||||
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||
<p className="min-w-0">{t("documents.ai.egressNotice")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{aiEnabled ? (
|
||||
<>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("documents.ai.available")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{indexEnabled ? (
|
||||
<Button
|
||||
|
||||
@@ -506,7 +506,6 @@ export function DocumentDetailSheet({
|
||||
aiEnabled={aiEnabled}
|
||||
indexEnabled={indexEnabled}
|
||||
unavailableReason={capability.data?.reason ?? null}
|
||||
egressExternal={capability.data?.egress === "external"}
|
||||
actionsDisabled={capability.isPending}
|
||||
onSuggest={runSuggest}
|
||||
suggestPending={suggest.isPending}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { Sigma } from "lucide-react";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
@@ -102,6 +102,21 @@ export interface MetricStatSeries {
|
||||
* all-time central value. Omit it to keep the generic label.
|
||||
*/
|
||||
medianLabel?: string;
|
||||
/**
|
||||
* v1.28.4 — optional descriptive line rendered INSIDE the tile, directly
|
||||
* under the `<TileHeader>` and above the four-stat grid. Lets a metric block
|
||||
* own its heading + blurb in a single tile rather than duplicating the
|
||||
* heading in an outer section above it. Muted supporting copy; omit it for
|
||||
* the numbers-only header the other subpages lead with.
|
||||
*/
|
||||
description?: ReactNode;
|
||||
/**
|
||||
* Render the series' `<TileHeader>` title as a real heading element (e.g.
|
||||
* "h2") rather than the presentational `CardTitle` div. Used where the tile
|
||||
* carries the ONLY heading on its subpage branch (the recovery blocks) so the
|
||||
* `h1 → h2` outline under `SubPageShell` stays nested with no skipped level.
|
||||
*/
|
||||
titleAs?: "h2";
|
||||
}
|
||||
|
||||
type MetricStatStripProps = Partial<MetricStatSeries> & {
|
||||
@@ -140,6 +155,8 @@ function SeriesBlock({
|
||||
windowStats,
|
||||
dataKey,
|
||||
medianLabel,
|
||||
description,
|
||||
titleAs,
|
||||
}: MetricStatSeries) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
@@ -183,7 +200,16 @@ function SeriesBlock({
|
||||
className="space-y-2"
|
||||
>
|
||||
{seriesLabel ? (
|
||||
<TileHeader icon={icon ?? Sigma} title={seriesLabel} />
|
||||
<TileHeader
|
||||
icon={icon ?? Sigma}
|
||||
title={seriesLabel}
|
||||
titleAs={titleAs}
|
||||
/>
|
||||
) : null}
|
||||
{description ? (
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-2 sm:grid-cols-4 [&>div]:min-h-[44px]">
|
||||
{cells.map((cell) => (
|
||||
@@ -215,6 +241,8 @@ export function MetricStatStrip({
|
||||
windowStats,
|
||||
dataKey,
|
||||
medianLabel,
|
||||
description,
|
||||
titleAs,
|
||||
series,
|
||||
groupLabel,
|
||||
}: MetricStatStripProps) {
|
||||
@@ -277,6 +305,8 @@ export function MetricStatStrip({
|
||||
windowStats={windowStats}
|
||||
dataKey={dataKey}
|
||||
medianLabel={medianLabel}
|
||||
description={description}
|
||||
titleAs={titleAs}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -71,28 +71,20 @@ export function RecoveryMetricBlock({
|
||||
|
||||
return (
|
||||
<section data-slot={`recovery-block-${type}`} className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
className="text-muted-foreground size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* v1.18.6 — block title is an `<h2>` (at `text-base`): the per-group
|
||||
sub-headings were removed, so each block now sits directly under
|
||||
the page `<h1>` from `SubPageShell`. Promoting it from `<h3>` keeps
|
||||
the recovery page's heading outline strictly nested (h1 → h2)
|
||||
with no skipped level. */}
|
||||
<h2 className="text-base font-semibold">{title}</h2>
|
||||
</div>
|
||||
<p className="text-foreground text-sm leading-relaxed">{explainer}</p>
|
||||
</div>
|
||||
|
||||
{/* v1.28.4 — the block no longer wraps the tile in an outer `<h2>` +
|
||||
explainer that the stat-strip's own `TileHeader` then repeated. The
|
||||
tile owns the single heading: `seriesLabel` becomes the block's `<h2>`
|
||||
(via `titleAs`, preserving the h1 → h2 nesting under `SubPageShell`)
|
||||
and the explainer renders inside the tile beneath it as `description`.
|
||||
The chart tile keeps its own canonical `TileHeader` naming itself. */}
|
||||
<MetricStatStrip
|
||||
summary={summary}
|
||||
unit={unit}
|
||||
fractionDigits={fractionDigits}
|
||||
seriesLabel={title}
|
||||
icon={Icon}
|
||||
titleAs="h2"
|
||||
description={explainer}
|
||||
windowStats={statsByType?.[type] ?? null}
|
||||
/>
|
||||
|
||||
|
||||
@@ -51,6 +51,14 @@ interface TileHeaderProps {
|
||||
titleClassName?: string;
|
||||
/** Optional id on the `CardTitle`, e.g. for an `aria-labelledby` link. */
|
||||
id?: string;
|
||||
/**
|
||||
* Render the title as a real heading element (e.g. `"h2"`) instead of the
|
||||
* default presentational `CardTitle` div, keeping the identical CardTitle
|
||||
* styling. Use only where the tile owns the ONLY heading on its subpage
|
||||
* branch (the recovery blocks) so the `h1 → h2` outline stays nested with no
|
||||
* skipped level. Default (unset) keeps every other tile a plain CardTitle.
|
||||
*/
|
||||
titleAs?: "h2";
|
||||
}
|
||||
|
||||
export function TileHeader({
|
||||
@@ -61,7 +69,12 @@ export function TileHeader({
|
||||
className,
|
||||
titleClassName,
|
||||
id,
|
||||
titleAs,
|
||||
}: TileHeaderProps) {
|
||||
const titleClassNames = cn(
|
||||
size === "sm" ? "text-sm" : "text-base",
|
||||
titleClassName,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
data-slot="tile-header"
|
||||
@@ -78,12 +91,21 @@ export function TileHeader({
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<CardTitle
|
||||
id={id}
|
||||
className={cn(size === "sm" ? "text-sm" : "text-base", titleClassName)}
|
||||
>
|
||||
{title}
|
||||
</CardTitle>
|
||||
{titleAs === "h2" ? (
|
||||
// Heading semantics with the exact CardTitle visual treatment so the
|
||||
// tile can carry a real `<h2>` where it owns the subpage's heading.
|
||||
<h2
|
||||
id={id}
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", titleClassNames)}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
) : (
|
||||
<CardTitle id={id} className={titleClassNames}>
|
||||
{title}
|
||||
</CardTitle>
|
||||
)}
|
||||
{right ? <div className="ml-auto flex items-center">{right}</div> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("supportsVisionForConfig", () => {
|
||||
expect(supportsVisionForConfig("local", null)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports vision for codex only on multimodal slugs", () => {
|
||||
it("reports vision for codex on multimodal slugs, including -codex", () => {
|
||||
for (const model of [
|
||||
"gpt-5.5",
|
||||
"gpt-5.4",
|
||||
@@ -79,6 +79,13 @@ describe("supportsVisionForConfig", () => {
|
||||
"gpt-4o",
|
||||
"gpt-4.1",
|
||||
"o1",
|
||||
// The GPT-5.x `-codex` specialists are vision-capable: the wire reads
|
||||
// an `input_image` block on these slugs (docs/codex-protocol-spec.md +
|
||||
// OpenAI's current model lineup). The old "-codex is text-only"
|
||||
// exclusion was stale.
|
||||
"gpt-5-codex",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.1-codex-mini",
|
||||
]) {
|
||||
expect(supportsVisionForConfig("codex", model)).toBe(true);
|
||||
}
|
||||
@@ -86,9 +93,6 @@ describe("supportsVisionForConfig", () => {
|
||||
expect(supportsVisionForConfig("codex", "gpt-4")).toBe(false);
|
||||
expect(supportsVisionForConfig("codex", "gpt-3.5-turbo")).toBe(false);
|
||||
expect(supportsVisionForConfig("codex", null)).toBe(false);
|
||||
// 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);
|
||||
|
||||
@@ -73,9 +73,10 @@ function openaiSupportsVision(model: string): boolean {
|
||||
/**
|
||||
* Codex (ChatGPT-OAuth) vision models. Image INPUT is reachable over the
|
||||
* `codex/responses` endpoint (docs/codex-protocol-spec.md §2b) on a multimodal
|
||||
* slug — no API key needed. The accepted GPT-5.x line is text+vision; the older
|
||||
* `-codex` specialist slugs and the legacy `gpt-4`-class non-multimodal pins
|
||||
* are text-only. The slug we test is the codex client's resolved working slug
|
||||
* slug — no API key needed. The whole GPT-5.x line is text+vision, including
|
||||
* the `-codex` specialist slugs (gpt-5.x-codex); only the legacy `gpt-4`-class
|
||||
* non-multimodal pins are text-only. The slug we test is the codex client's
|
||||
* resolved working slug
|
||||
* (cached or chain head), so a slug rotation onto a non-vision model degrades
|
||||
* to false rather than 400-ing the user at the image block.
|
||||
*
|
||||
@@ -85,10 +86,14 @@ function openaiSupportsVision(model: string): boolean {
|
||||
function codexSupportsVision(model: string): boolean {
|
||||
const m = model.toLowerCase();
|
||||
return (
|
||||
// GPT-5.x line is multimodal (text + vision) — but the `-codex` specialist
|
||||
// slugs (e.g. gpt-5-codex) are text-only, so they must not claim vision or
|
||||
// every lab-OCR upload burns a doomed image attempt before falling back.
|
||||
(m.startsWith("gpt-5") && !m.includes("-codex")) ||
|
||||
// GPT-5.x line is multimodal (text + vision) — INCLUDING the `-codex`
|
||||
// specialist slugs (gpt-5.x-codex), which gained vision in the 5.2-Codex
|
||||
// line (screenshots / diagrams / charts). Verified against
|
||||
// docs/codex-protocol-spec.md (an `input_image` block on a gpt-5-codex
|
||||
// request) plus OpenAI's current model lineup. The historical
|
||||
// "-codex is text-only" exclusion was stale — the wire reads images on
|
||||
// these slugs, so lab-OCR and document reads both succeed on a `-codex` pin.
|
||||
m.startsWith("gpt-5") ||
|
||||
m.startsWith("gpt-4o") ||
|
||||
m.startsWith("gpt-4.1") ||
|
||||
m.startsWith("gpt-4-turbo") ||
|
||||
|
||||
Reference in new issue
Block a user