diff --git a/docs/codex-protocol-spec.md b/docs/codex-protocol-spec.md index 826d6c03b..de74b0198 100644 --- a/docs/codex-protocol-spec.md +++ b/docs/codex-protocol-spec.md @@ -630,7 +630,8 @@ Confirmed-rejected slugs (despite the source-tree references in the table above) Confirmed-accepted slugs on a ChatGPT Plus subscription (per `available_in_plans` in `models.json`): -- `gpt-5.6-terra` — HealthLog's default (first chain entry) as of 2026-10-24, with `gpt-5.6-luna` and `gpt-5.6-sol` behind it; the backend rejected `gpt-5.5` and everything below it that day +- `gpt-5.6-terra` — HealthLog's default (first chain entry) as of 2026-10-24; the backend rejected `gpt-5.5` and everything below it that day. `gpt-5.6-luna` and `gpt-5.6-sol` are its siblings on the same line +- `gpt-6-sol`, `gpt-6.1-sol`, `gpt-6-luna`, `gpt-6-astra` — the gpt-6 line, listed in `models.json` since 2026-09-22 (`gpt-6.1-sol` the catalog default since 2026-09-29). The catalog names `gpt-6-sol` as the upgrade of `gpt-5.6-terra` and `gpt-5.6-sol`, and `gpt-6-luna` as that of `gpt-5.6-luna`. Every gpt-6 slug lists `input_modalities: ["text", "image"]` and the same reasoning presets as the gpt-5.6 line (`low` to `max`, sol and astra also `ultra`). The presets omit `none`, as they do for `gpt-5.5`, which a live probe on 2026-10-06 showed accepts it; HealthLog sends `none` for "off" on the gpt-6 line too and learns a refusal per slug. The gpt-6 slugs were not probed against a live backend when they joined the chain - `gpt-5.5` — the default from 2026-06 until 2026-10-24 - `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.2` — accepted per plan - `gpt-5.3-codex` — was the default (verified 2026-05-09); the backend rotated off the `-codex` line on 2026-06-02 and this slug is now only accepted intermittently, so it sits on a late chain rung @@ -643,18 +644,23 @@ The ChatGPT-Codex allow-list is rotated by OpenAI without prior notice (`gpt-5-c To eliminate that risk class, HealthLog's `CodexClient` walks an ordered chain on every fresh request series. -**Chain** (top = first try; default order is the empirically-safest most-current-first ordering as of 2026-06, after the backend rotated off the `-codex` line on 2026-06-02): +**Chain** (top = first try). `gpt-5.6-terra` leads because it is the slug verified against the backend. The gpt-6 rungs follow before terra's own siblings: the catalog names `gpt-6-sol` as terra's upgrade, and the 2026-10-24 rotation retired a whole line at once, so a rotation that takes terra most likely takes `gpt-5.6-luna` and `gpt-5.6-sol` with it. `gpt-6.1-sol` (the catalog default, also on plans `gpt-6-sol` is not offered on) and `gpt-6-luna` (the lighter one) come next. `gpt-6-astra`, the frontier tier, stays out of the default: ```ts const DEFAULT_SLUG_FALLBACK_CHAIN = [ - "gpt-5.6-terra", // current default, verified against the backend 2026-10-24 - "gpt-5.6-luna", // lighter sibling on the same line - "gpt-5.6-sol", // heavier sibling on the same line + "gpt-5.6-terra", // verified against the backend 2026-10-24 + "gpt-6-sol", // terra's upgrade in the upstream catalog + "gpt-6.1-sol", // the catalog default, on every plan + "gpt-6-luna", // lighter gpt-6 sibling + "gpt-5.6-luna", // lighter sibling on terra's line + "gpt-5.6-sol", // heavier sibling on terra's line "gpt-5.5", // previous default, kept as the last rung while plans differ ]; ``` -The shipped constant in `src/lib/ai/codex-client.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows. +Every default rung must be a model family the reasoning, sampling and vision gates recognise (`src/lib/ai/openai-model-family.ts`); `codex-slug-fallback.test.ts` holds the chain to that. + +The shipped constant in `src/lib/ai/codex-slug-chain.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows. The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated). The first env-var-named slug supersedes `CODEX_MODEL`; if both are set, `CODEX_MODEL` is folded into position 0 and de-duplicated. diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index a63b419a0..bf4ee77d3 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 89, - "total": 99565, + "total": 99521, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -29,7 +29,7 @@ "lib/ai/coach/stream-events.ts": 817, "lib/ai/coach/turn/model.ts": 820, "lib/ai/coach/types.ts": 1011, - "lib/ai/codex-client.ts": 1089, + "lib/ai/codex-client.ts": 1045, "lib/ai/local-client.ts": 880, "lib/ai/prompts/insight-generator.ts": 1110, "lib/ai/provider.ts": 1632, diff --git a/src/components/settings/ai/shared.ts b/src/components/settings/ai/shared.ts index 4b87c04dc..02e763029 100644 --- a/src/components/settings/ai/shared.ts +++ b/src/components/settings/ai/shared.ts @@ -99,10 +99,13 @@ export const DEFAULT_CHAIN: readonly ChainEntry[] = [ * OpenAI model presets — the wire-up keeps users out of the freetext * box for the common case. The `__custom__` sentinel surfaces a * freetext input so power users can target preview models or - * fine-tunes (e.g. `gpt-5`, which is admitted via the env override on - * the Codex backend but not yet a documented OpenAI model id). + * fine-tunes. The gpt-6 entries are the current workhorse and its + * lighter sibling; every preset is a family `openai-model-family.ts` + * recognises, so the reasoning, token-field and vision gates fit it. */ export const OPENAI_MODEL_PRESETS = [ + "gpt-6.1-sol", + "gpt-6-luna", "gpt-4o", "gpt-4o-mini", "gpt-4-turbo", diff --git a/src/lib/ai/__tests__/codex-slug-fallback.test.ts b/src/lib/ai/__tests__/codex-slug-fallback.test.ts index c7ea32c68..860cbfd39 100644 --- a/src/lib/ai/__tests__/codex-slug-fallback.test.ts +++ b/src/lib/ai/__tests__/codex-slug-fallback.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { CodexClient, __test } from "../codex-client"; import { singleUserTurn } from "../types"; -import { openAIReasoningFamily } from "../reasoning/support"; +import { + openAIModelFamily, + type OpenAIReasoningFamily, +} from "../openai-model-family"; +import { openAIOffEffort } from "../reasoning/support"; +import { supportsVisionForConfig } from "../vision-capability"; import { clearCodexSlugCache, getCachedCodexSlug, @@ -343,22 +348,39 @@ describe("Codex slug fallback chain", () => { it("DEFAULT_SLUG_FALLBACK_CHAIN tracks the current ChatGPT-auth ladder", () => { // The backend rejected every slug from gpt-5.5 down on 2026-10-24 and - // served the gpt-5.6 line; the in-code default must match the accepted - // set, in order. + // served the gpt-5.6 and gpt-6 lines. Terra, verified live, leads; its + // upstream successor and the rest of the gpt-6 line follow before terra's + // own siblings, which a line rotation would retire together with it. expect([...__test.DEFAULT_SLUG_FALLBACK_CHAIN]).toEqual([ "gpt-5.6-terra", + "gpt-6-sol", + "gpt-6.1-sol", + "gpt-6-luna", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.5", ]); }); - it("DEFAULT_SLUG_FALLBACK_CHAIN holds only slugs the reasoning gate recognises", () => { - // A slug whose family the reasoning gate does not know goes out with the - // wrong parameters. A new line (gpt-6) joins the default only together - // with its family support. + it("DEFAULT_SLUG_FALLBACK_CHAIN holds only slugs every family gate recognises", () => { + // A slug whose family the gates do not know goes out with the wrong + // reasoning parameters and is denied image input. Every rung, whatever + // its generation, must be a recognised reasoning family with a real + // "off" (the Codex client sends `effort: "none"` for it) that reads + // images. Nothing is filtered out first: a new line joins the default + // only together with its family support. + expect(__test.DEFAULT_SLUG_FALLBACK_CHAIN.length).toBeGreaterThan(0); for (const slug of __test.DEFAULT_SLUG_FALLBACK_CHAIN) { - expect(openAIReasoningFamily(slug), slug).toBe("gpt-5.1+"); + const family = openAIModelFamily(slug); + expect(family, slug).not.toBeNull(); + expect(family?.reasoning, slug).not.toBeNull(); + expect( + openAIOffEffort( + family!.reasoning as NonNullable, + ), + slug, + ).toBe("none"); + expect(supportsVisionForConfig("codex", slug), slug).toBe(true); } }); @@ -370,6 +392,9 @@ describe("Codex slug fallback chain", () => { try { expect(__test.loadFallbackChain()).toEqual([ "gpt-5.6-terra", + "gpt-6-sol", + "gpt-6.1-sol", + "gpt-6-luna", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.5", diff --git a/src/lib/ai/__tests__/openai-client.test.ts b/src/lib/ai/__tests__/openai-client.test.ts index ae73f6714..5fd120588 100644 --- a/src/lib/ai/__tests__/openai-client.test.ts +++ b/src/lib/ai/__tests__/openai-client.test.ts @@ -157,6 +157,9 @@ describe("OpenAIClient", () => { ["o3-mini", 321, "https://api.openai.com/v1"], ["o4-mini-2025-04-16", 321, "https://api.openai.com/v1"], ["gpt-5.4-mini", 321, "https://API.OPENAI.COM:443/v1/"], + ["gpt-6-sol", 321, "https://api.openai.com/v1"], + ["gpt-6-luna", 1000, "https://api.openai.com/v1"], + ["gpt-6.1-sol", 321, "https://api.openai.com/v1"], ])( "uses the canonical modern token wire for %s", async (model, expectedBudget, baseUrl) => { diff --git a/src/lib/ai/__tests__/openai-model-family.test.ts b/src/lib/ai/__tests__/openai-model-family.test.ts new file mode 100644 index 000000000..1e0fdd08b --- /dev/null +++ b/src/lib/ai/__tests__/openai-model-family.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { OPENAI_MODEL_PRESETS } from "@/components/settings/ai/shared"; +import { openAIModelFamily } from "../openai-model-family"; + +describe("openAIModelFamily", () => { + it.each([ + // The gpt-6 line as the upstream Codex catalog lists it. + ["gpt-6-sol", "gpt-5.1+", true], + ["gpt-6-luna", "gpt-5.1+", true], + ["gpt-6-astra", "gpt-5.1+", true], + ["gpt-6.1-sol", "gpt-5.1+", true], + ["gpt-6", "gpt-5.1+", true], + ["GPT-6.1-Sol", "gpt-5.1+", true], + ["openai/gpt-6-luna", "gpt-5.1+", true], + // A later generation reads like the newest one this file knows. + ["gpt-7", "gpt-5.1+", true], + // gpt-5.x + ["gpt-5.6-terra", "gpt-5.1+", true], + ["gpt-5.5", "gpt-5.1+", true], + ["gpt-5.3-codex", "gpt-5.1+", true], + ["gpt-5.1-codex-mini", "gpt-5.1+", true], + ["gpt-5", "gpt-5", true], + ["gpt-5-mini", "gpt-5", true], + ["gpt-5-codex", "gpt-5", true], + ["gpt-5-2025-08-07", "gpt-5", true], + // o-series: full models read images, -mini / -preview do not. + ["o1", "o", true], + ["o3", "o", true], + ["o4", "o", true], + ["o1-mini", "o", false], + ["o1-preview", "o", false], + ["o3-mini", "o", false], + ["o4-mini-2025-04-16", "o", false], + // gpt-4 class: no reasoning; vision on 4o, 4.1 and turbo only. + ["gpt-4o", null, true], + ["gpt-4o-mini", null, true], + ["gpt-4.1", null, true], + ["gpt-4.1-mini", null, true], + ["gpt-4-turbo", null, true], + ["gpt-4", null, false], + ["gpt-3.5-turbo", null, false], + ] as const)("%s", (model, reasoning, vision) => { + expect(openAIModelFamily(model)).toEqual({ reasoning, vision }); + }); + + it("does not recognise names outside the OpenAI families", () => { + for (const model of [ + "llama3.1:8b", + "claude-opus-5", + "gpt-daybreak-blue-latest", + "gpt-60x", + "o2", + "o3x", + "", + ]) { + expect(openAIModelFamily(model), model).toBeNull(); + } + }); + + it("recognises every OpenAI model the settings offer as a preset", () => { + for (const model of OPENAI_MODEL_PRESETS) { + expect(openAIModelFamily(model), model).not.toBeNull(); + } + }); +}); diff --git a/src/lib/ai/__tests__/openai-reasoning.test.ts b/src/lib/ai/__tests__/openai-reasoning.test.ts index 0e0d53b10..176ce2763 100644 --- a/src/lib/ai/__tests__/openai-reasoning.test.ts +++ b/src/lib/ai/__tests__/openai-reasoning.test.ts @@ -137,6 +137,24 @@ describe("OpenAI reasoning", () => { expect(body(mock, 2).reasoning_effort).toBe("low"); expect(body(mock, 3)).not.toHaveProperty("reasoning_effort"); }); + + it("controls reasoning on the gpt-6 line like the gpt-5.1+ line", async () => { + const mock = vi.fn().mockImplementation(plain("ok")); + vi.stubGlobal("fetch", mock); + await openai("gpt-6-sol").generateCompletion( + params({ reasoning: { effort: "off", summaries: false } }), + ); + await openai("gpt-6.1-sol").generateCompletion( + params({ + temperature: 0.3, + reasoning: { effort: "medium", summaries: false }, + }), + ); + expect(body(mock, 0).reasoning_effort).toBe("none"); + expect(body(mock, 1).reasoning_effort).toBe("medium"); + expect(body(mock, 1).max_completion_tokens).toBe(600 + 4096); + expect(body(mock, 1)).not.toHaveProperty("temperature"); + }); }); describe("Gateway reasoning", () => { diff --git a/src/lib/ai/__tests__/vision-capability.test.ts b/src/lib/ai/__tests__/vision-capability.test.ts index 1eb2c47e1..08196c001 100644 --- a/src/lib/ai/__tests__/vision-capability.test.ts +++ b/src/lib/ai/__tests__/vision-capability.test.ts @@ -5,6 +5,14 @@ import { supportsPdfForProvider, } from "../vision-capability"; +const GPT6_SLUGS = [ + "gpt-6-sol", + "gpt-6-luna", + "gpt-6-astra", + "gpt-6.1-sol", + "gpt-6", +] as const; + describe("supportsVisionForConfig", () => { it("accepts the Claude 3 / 3.5 / 3.7 / 4 families for anthropic", () => { for (const model of [ @@ -60,6 +68,20 @@ describe("supportsVisionForConfig", () => { } }); + it("accepts the gpt-6 line for openai + admin variants", () => { + // Every gpt-6 slug in the upstream catalog lists `input_modalities: + // ["text", "image"]` (codex-rs/models-manager/models.json). + for (const providerType of [ + "openai", + "admin-openai", + "admin-key", + ] as const) { + for (const model of GPT6_SLUGS) { + expect(supportsVisionForConfig(providerType, model), model).toBe(true); + } + } + }); + it("rejects non-vision openai models", () => { expect(supportsVisionForConfig("openai", "gpt-3.5-turbo")).toBe(false); expect(supportsVisionForConfig("openai", "gpt-4")).toBe(false); @@ -115,6 +137,14 @@ describe("supportsVisionForConfig", () => { expect(supportsVisionForConfig("codex", "o3")).toBe(true); }); + it("reports vision for codex and admin-codex on the gpt-6 line", () => { + for (const providerType of ["codex", "admin-codex"] as const) { + for (const model of GPT6_SLUGS) { + expect(supportsVisionForConfig(providerType, model), model).toBe(true); + } + } + }); + it("mirrors codex vision capability for the shared central codex (admin-codex)", () => { // The operator-shared central Codex runs the same wire on the same resolved // slug, so its vision gate is identical to `codex`. diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 86c034022..93ad5b38a 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -24,6 +24,12 @@ import { invalidateCachedCodexSlug, setCachedCodexSlug, } from "./codex-slug-cache"; +import { + DEFAULT_SLUG_FALLBACK_CHAIN, + loadFallbackChain, +} from "./codex-slug-chain"; + +export { resolveCodexVisionSlug } from "./codex-slug-chain"; /** * Codex backend client — talks to `chatgpt.com/backend-api/codex/responses` @@ -50,56 +56,6 @@ import { const CODEX_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"; -/** - * Default fallback chain, most-current-first as of 2026-10-24. Override via - * `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated); `CODEX_MODEL` is folded - * into position 0 when set, duplicates dropped. The backend rotates its - * ChatGPT-auth slugs without notice: by 2026-10-24 it rejected everything - * from `gpt-5.5` down and served the `gpt-5.6-*` line. The `gpt-6` line is - * left out until the reasoning, sampling and vision gates recognise it. - */ -const DEFAULT_SLUG_FALLBACK_CHAIN = [ - "gpt-5.6-terra", // current default, verified against the backend 2026-10-24 - "gpt-5.6-luna", // lighter sibling on the same line - "gpt-5.6-sol", // heavier sibling on the same line - "gpt-5.5", // previous default, kept as the last rung while plans differ -] as const; - -/** - * The slug the codex vision gate reasons about *before* a request runs: the - * cached working slug if one is live, else the first chain entry. Lets the - * Lab-OCR capability resolver decide whether codex can read images without - * standing up a client or making a network call. Best-effort — the actual - * routed slug may differ once the chain walk runs, which the runtime probe + - * graceful fallback (text/local OCR) covers. - */ -export function resolveCodexVisionSlug(): string | null { - const cached = getCachedCodexSlug(); - if (cached) return cached; - return loadFallbackChain()[0] ?? null; -} - -function loadFallbackChain(): string[] { - const envChain = process.env.CODEX_MODEL_FALLBACK_CHAIN?.trim(); - const fromEnv = envChain - ? envChain - .split(",") - .map((s) => s.trim()) - .filter(Boolean) - : [...DEFAULT_SLUG_FALLBACK_CHAIN]; - const pinned = process.env.CODEX_MODEL?.trim(); - const merged = pinned ? [pinned, ...fromEnv] : fromEnv; - // Stable de-duplication. - const seen = new Set(); - const result: string[] = []; - for (const slug of merged) { - if (seen.has(slug)) continue; - seen.add(slug); - result.push(slug); - } - return result; -} - /** * Triggers per spec §7b — body / status that mean "this slug is no * longer accepted, walk to the next one in the chain". diff --git a/src/lib/ai/codex-slug-chain.ts b/src/lib/ai/codex-slug-chain.ts new file mode 100644 index 000000000..550f3a24d --- /dev/null +++ b/src/lib/ai/codex-slug-chain.ts @@ -0,0 +1,69 @@ +/** + * The Codex slug ladder: which ChatGPT-auth model slugs the client tries, in + * order, and the slug the vision gate reasons about before a request runs. + * The walk itself (which statuses move to the next rung, the positive cache) + * lives in `codex-client.ts` and `codex-slug-cache.ts`; docs/codex-protocol-spec.md + * §7b describes the whole defence. + */ +import { getCachedCodexSlug } from "./codex-slug-cache"; + +/** + * Default fallback chain. Override via `CODEX_MODEL_FALLBACK_CHAIN` + * (comma-separated); `CODEX_MODEL` is folded into position 0 when set, + * duplicates dropped. The backend rotates its ChatGPT-auth slugs without + * notice: by 2026-10-24 it rejected everything from `gpt-5.5` down. + * + * `gpt-5.6-terra` stays first because it is the slug verified against the + * backend. The upstream catalog (`codex-rs/models-manager/models.json`) + * names `gpt-6-sol` as its upgrade, and the last rotation retired a whole + * line at once, so the gpt-6 rungs come next rather than terra's siblings: + * `gpt-6-sol` (terra's named successor), `gpt-6.1-sol` (the catalog's + * default, also offered on plans `gpt-6-sol` is not), `gpt-6-luna` (the + * lighter one). `gpt-6-astra`, the frontier tier, is left out: it spends a + * plan's allowance fastest for no gain on these prompts. + */ +export const DEFAULT_SLUG_FALLBACK_CHAIN = [ + "gpt-5.6-terra", // verified against the backend 2026-10-24 + "gpt-6-sol", // terra's upgrade in the upstream catalog + "gpt-6.1-sol", // the catalog default, on every plan + "gpt-6-luna", // lighter gpt-6 sibling + "gpt-5.6-luna", // lighter sibling on terra's line + "gpt-5.6-sol", // heavier sibling on terra's line + "gpt-5.5", // previous default, kept as the last rung while plans differ +] as const; + +/** + * The slug the codex vision gate reasons about *before* a request runs: the + * cached working slug if one is live, else the first chain entry. Lets the + * Lab-OCR capability resolver decide whether codex can read images without + * standing up a client or making a network call. Best-effort — the actual + * routed slug may differ once the chain walk runs, which the runtime probe + + * graceful fallback (text/local OCR) covers. + */ +export function resolveCodexVisionSlug(): string | null { + const cached = getCachedCodexSlug(); + if (cached) return cached; + return loadFallbackChain()[0] ?? null; +} + +/** The chain a client walks: the env override or the default, `CODEX_MODEL` first. */ +export function loadFallbackChain(): string[] { + const envChain = process.env.CODEX_MODEL_FALLBACK_CHAIN?.trim(); + const fromEnv = envChain + ? envChain + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : [...DEFAULT_SLUG_FALLBACK_CHAIN]; + const pinned = process.env.CODEX_MODEL?.trim(); + const merged = pinned ? [pinned, ...fromEnv] : fromEnv; + // Stable de-duplication. + const seen = new Set(); + const result: string[] = []; + for (const slug of merged) { + if (seen.has(slug)) continue; + seen.add(slug); + result.push(slug); + } + return result; +} diff --git a/src/lib/ai/openai-capabilities.ts b/src/lib/ai/openai-capabilities.ts index 1766ec05d..407b6c2e4 100644 --- a/src/lib/ai/openai-capabilities.ts +++ b/src/lib/ai/openai-capabilities.ts @@ -1,3 +1,5 @@ +import { openAIModelFamily } from "./openai-model-family"; + export type OpenAIChatCompletionsCapabilities = | { tokenBudgetField: "max_completion_tokens"; @@ -18,20 +20,13 @@ const LEGACY_COMPATIBLE_CAPABILITIES = { supportsSamplingControls: true, } as const; +/** + * A reasoning model (gpt-5 on, the gpt-6 line included, the o-series) takes + * `max_completion_tokens` and refuses sampling controls; the family reading + * is shared with the reasoning and vision gates. + */ function isModernCanonicalModel(model: string): boolean { - const normalizedModel = model.toLowerCase(); - - return ( - normalizedModel === "gpt-5" || - normalizedModel.startsWith("gpt-5-") || - normalizedModel.startsWith("gpt-5.") || - normalizedModel === "o1" || - normalizedModel.startsWith("o1-") || - normalizedModel === "o3" || - normalizedModel.startsWith("o3-") || - normalizedModel === "o4" || - normalizedModel.startsWith("o4-") - ); + return openAIModelFamily(model)?.reasoning != null; } export function isCanonicalOpenAIEndpoint(baseUrl: string): boolean { diff --git a/src/lib/ai/openai-model-family.ts b/src/lib/ai/openai-model-family.ts new file mode 100644 index 000000000..20c9e6f99 --- /dev/null +++ b/src/lib/ai/openai-model-family.ts @@ -0,0 +1,88 @@ +/** + * The one reading of an OpenAI model id. Every gate that decides something + * from an OpenAI or Codex model name asks this function: the reasoning effort + * and its "off" value (`reasoning/support.ts`), the Chat Completions token + * field and sampling controls (`openai-capabilities.ts`), and image input + * (`vision-capability.ts`). A new generation is taught here once instead of + * in three regexes that drift apart. + * + * The facts behind each branch: + * + * - GPT from 5.1 on, the gpt-6 line included, reasons and takes + * `effort: "none"`. A live probe on 2026-10-06 answered `none` on gpt-5.5 + * and listed `'none', 'low', 'medium', 'high', 'xhigh'` as supported. + * The upstream Codex catalog (`codex-rs/models-manager/models.json`) + * gives gpt-6-sol, gpt-6-luna, gpt-6-astra and gpt-6.1-sol the same + * reasoning presets as the gpt-5.6 line (`low` to `max`, sol and astra + * also `ultra`). The catalog lists presets, not the wire set: it leaves + * `none` out for gpt-5.5 too, which takes it. A model that does refuse + * `none` is asked again without reasoning and remembers that + * (`reasoning/dialect-cache.ts`), so the assumption costs one retry at + * worst. A later generation (`gpt-7`) is read the same way. + * - Bare gpt-5 (and its mini / nano / dated snapshots) stops at `minimal`. + * - The o-series (o1, o3, o4) starts at `low`. + * - Image input: every GPT from 5 on (the catalog lists + * `input_modalities: ["text", "image"]` for every gpt-5.x and gpt-6 + * slug), gpt-4o, gpt-4.1, gpt-4-turbo, and the full o1 / o3 / o4. The + * o-series `-mini` / `-preview` variants are text-only, as are gpt-4 and + * gpt-3.5. + * + * Client-safe: no server import. + */ + +/** + * The reasoning family of an OpenAI model, or `null` for a model that does + * not reason. `gpt-5.1+` is every GPT generation from 5.1 on, the gpt-6 line + * included. + */ +export type OpenAIReasoningFamily = "gpt-5.1+" | "gpt-5" | "o" | null; + +export interface OpenAIModelFamily { + /** How the model takes a reasoning effort; `null` when it does not reason. */ + reasoning: OpenAIReasoningFamily; + /** The model reads image input. */ + vision: boolean; +} + +/** + * `gpt-[.][o]`, followed by the end or a `-variant`: + * `gpt-6.1-sol`, `gpt-5-codex-mini`, `gpt-4o-mini`, `gpt-4-turbo`. + */ +const GPT_ID = /^gpt-(\d+)(?:\.(\d+))?(o?)(?=$|-)/; +const O_SERIES_ID = /^o[134](?=$|-)/; + +/** + * What the model family behind `model` can do, or `null` for a name this + * file does not recognise as an OpenAI model (a gateway alias, a local + * model, a hidden codename slug). An `openai/` gateway prefix is ignored. + */ +export function openAIModelFamily(model: string): OpenAIModelFamily | null { + const m = model + .trim() + .toLowerCase() + .replace(/^openai\//, ""); + + const gpt = GPT_ID.exec(m); + if (gpt) { + const major = Number(gpt[1]); + const minor = gpt[2] === undefined ? null : Number(gpt[2]); + const omni = gpt[3] === "o"; + if (major >= 6 || (major === 5 && minor !== null && minor >= 1)) { + return { reasoning: "gpt-5.1+", vision: true }; + } + if (major === 5) return { reasoning: "gpt-5", vision: true }; + const rest = m.slice(gpt[0].length); + const vision = + major === 4 && (omni || minor === 1 || rest.startsWith("-turbo")); + return { reasoning: null, vision }; + } + + if (O_SERIES_ID.test(m)) { + return { + reasoning: "o", + vision: !m.includes("-mini") && !m.includes("-preview"), + }; + } + + return null; +} diff --git a/src/lib/ai/reasoning/__tests__/support.test.ts b/src/lib/ai/reasoning/__tests__/support.test.ts index 3f2fc7dcb..97003ab72 100644 --- a/src/lib/ai/reasoning/__tests__/support.test.ts +++ b/src/lib/ai/reasoning/__tests__/support.test.ts @@ -74,6 +74,11 @@ describe("OpenAI families", () => { it.each([ ["gpt-5.5", "gpt-5.1+", "none"], ["gpt-5.1-mini", "gpt-5.1+", "none"], + ["gpt-6-sol", "gpt-5.1+", "none"], + ["gpt-6-luna", "gpt-5.1+", "none"], + ["gpt-6-astra", "gpt-5.1+", "none"], + ["gpt-6.1-sol", "gpt-5.1+", "none"], + ["openai/gpt-6-sol", "gpt-5.1+", "none"], ["gpt-5", "gpt-5", "minimal"], ["gpt-5-mini", "gpt-5", "minimal"], ["o3", "o", "low"], @@ -83,6 +88,15 @@ describe("OpenAI families", () => { expect(openAIOffEffort(family)).toBe(off); }); + it("gives the gpt-6 line an effort and a real off on the operator's key", () => { + for (const model of ["gpt-6-sol", "gpt-6.1-sol", "gpt-6-luna"]) { + expect(reasoningSupport("admin-key", model), model).toMatchObject({ + effort: true, + offIsReal: true, + }); + } + }); + it("does not treat a non-reasoning model as one", () => { expect(openAIReasoningFamily("gpt-4o")).toBeNull(); expect(openAIReasoningFamily("gpt-4.1-mini")).toBeNull(); diff --git a/src/lib/ai/reasoning/support.ts b/src/lib/ai/reasoning/support.ts index b1d6d0615..20d34e642 100644 --- a/src/lib/ai/reasoning/support.ts +++ b/src/lib/ai/reasoning/support.ts @@ -20,8 +20,8 @@ * https://developers.openai.com/cookbook/examples/responses_api/reasoning_items * - OpenAI Chat Completions: `reasoning_effort`, no summaries and no * reasoning items to hand back between tool rounds, so reasoning is only - * sent on rounds without tools. `none` exists from gpt-5.1 on; gpt-5 knows - * `minimal`; the o-series starts at `low`. + * sent on rounds without tools. `none` exists from gpt-5.1 on (the gpt-6 + * line included); gpt-5 knows `minimal`; the o-series starts at `low`. * https://platform.openai.com/docs/api-reference/chat/create * - Anthropic: manual `thinking: { type: "enabled", budget_tokens }` up to * the 4.5 generation; adaptive `thinking: { type: "adaptive" }` with @@ -46,6 +46,10 @@ * * Client-safe: no server import. */ +import { + openAIModelFamily, + type OpenAIReasoningFamily, +} from "../openai-model-family"; import type { ProviderType } from "../types"; import type { ReasoningLevel } from "./levels"; @@ -147,19 +151,17 @@ export function anthropicThinksAlways(model: string): boolean { // ── OpenAI ────────────────────────────────────────────────────────────────── +export type { OpenAIReasoningFamily }; + /** * The reasoning family of an OpenAI model id, or `null` for a model that does - * not reason (`gpt-4o`, `gpt-4.1`). `gpt-5.1+` knows `none`, bare `gpt-5` - * (and its mini / nano) stops at `minimal`, the o-series at `low`. + * not reason (`gpt-4o`, `gpt-4.1`) or is not recognised. `gpt-5.1+` (every + * generation from 5.1 on, the gpt-6 line included) knows `none`, bare `gpt-5` + * (and its mini / nano) stops at `minimal`, the o-series at `low`. The + * reading itself lives in `openai-model-family.ts`. */ -export type OpenAIReasoningFamily = "gpt-5.1+" | "gpt-5" | "o" | null; - export function openAIReasoningFamily(model: string): OpenAIReasoningFamily { - const m = model.toLowerCase().replace(/^openai\//, ""); - const gpt5 = /^gpt-5(?:\.(\d+))?(?:$|-)/.exec(m); - if (gpt5) return gpt5[1] && Number(gpt5[1]) >= 1 ? "gpt-5.1+" : "gpt-5"; - if (/^o[134](?:$|-)/.test(m)) return "o"; - return null; + return openAIModelFamily(model)?.reasoning ?? null; } /** The lowest `reasoning_effort` an OpenAI reasoning family accepts. */ diff --git a/src/lib/ai/vision-capability.ts b/src/lib/ai/vision-capability.ts index 2a2eacae5..2617f2f1b 100644 --- a/src/lib/ai/vision-capability.ts +++ b/src/lib/ai/vision-capability.ts @@ -12,6 +12,7 @@ * still resolve to a dead provider at call time (revoked key, unreachable * local host); the route surfaces that as an extract failure. */ +import { openAIModelFamily } from "./openai-model-family"; /** * Anthropic vision models. The Messages API `image` + `document` blocks are @@ -45,69 +46,22 @@ 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 full `o1` / `o3` / `o4` reasoning models - * read image content; `gpt-3.5*` and the legacy `gpt-4` (non-turbo) 400 on an - * image block. + * OpenAI and Codex vision models. Both read the same model families, so both + * ask `openAIModelFamily`, which owns the list: every GPT from 5 on (the + * gpt-6 line and the `-codex` specialists included), gpt-4o, gpt-4.1, + * gpt-4-turbo and the full o1 / o3 / o4. The o-series `-mini` / `-preview` + * variants are text-only and must stay excluded, or a user pinned to one is + * told OCR works and the provider 400s at the image block; so are gpt-4 and + * gpt-3.5. * - * 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. + * For Codex (ChatGPT OAuth), image input rides the `codex/responses` + * endpoint (docs/codex-protocol-spec.md §2b) with no API key. The slug tested + * is the codex client's resolved working slug (cached or chain head), so a + * slug rotation onto a model this reading does not know degrades to false + * rather than 400-ing the user at the image block. */ -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-5") || - m.startsWith("gpt-4o") || - m.startsWith("gpt-4.1") || - m.startsWith("gpt-4-turbo") || - isVisionReasoningSlug(m) - ); -} - -/** - * 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 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. - * - * `gpt-4o` / `o*` are folded in for completeness (a future codex slug could - * land there), reusing the same multimodal families the OpenAI gate accepts. - */ -function codexSupportsVision(model: string): boolean { - const m = model.toLowerCase(); - return ( - // 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") || - // 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) - ); +function openAIFamilySupportsVision(model: string): boolean { + return openAIModelFamily(model)?.vision ?? false; } /** @@ -130,7 +84,8 @@ export type VisionProviderType = * Pure capability check over a (providerType, model) pair. * * - anthropic → vision when the model is a 3.5+/4 family pin. - * - openai / admin-* → vision when the model is a gpt-4o/4.1/turbo/o* pin. + * - openai / admin-* → vision when the model is a gpt-5+/4o/4.1/turbo/o* + * pin (the gpt-6 line included). * - local → trust-by-default true: the operator opted in by * configuring a self-hosted model and we cannot sniff * its capabilities; a non-vision local model surfaces @@ -141,7 +96,7 @@ export type VisionProviderType = * (and accept incapable ones) on the strength of a * string the gateway owner picked. * - codex → vision when the resolved codex slug is a - * multimodal model (GPT-5.x / gpt-4o-class). Image + * multimodal model (GPT-5.x, gpt-6, gpt-4o-class). Image * input rides the ChatGPT plan — no API key. A * non-multimodal slug (or unknown) → false. * - none → false (nothing configured). @@ -156,15 +111,15 @@ export function supportsVisionForConfig( case "openai": case "admin-openai": case "admin-key": - return model ? openaiSupportsVision(model) : false; + return model ? openAIFamilySupportsVision(model) : false; case "local": case "openai-compatible": return true; case "codex": // The operator-shared central Codex runs the same `codex/responses` wire on - // a resolved GPT-5.x slug, so its vision capability is identical to `codex`. + // a resolved Codex slug, so its vision capability is identical to `codex`. case "admin-codex": - return model ? codexSupportsVision(model) : false; + return model ? openAIFamilySupportsVision(model) : false; case "none": default: return false;