fix(ai): recognise the gpt-6 model family in every model gate

The ChatGPT Codex backend and the OpenAI API serve gpt-6-sol, gpt-6-luna,
gpt-6-astra and gpt-6.1-sol, but the reasoning, Chat Completions and vision
gates only knew the gpt-5 line. A gpt-6 model got no reasoning control and
no real "off", went out on the legacy wire with max_tokens and a
temperature that reasoning models refuse, and was denied image input.

The three gates now read one classifier, openai-model-family.ts, so a new
generation is taught in one place. Every GPT from 5.1 on, the gpt-6 line
included, is the family that takes effort "none"; every GPT from 5 on reads
images. The upstream catalog (codex-rs/models-manager/models.json) lists
image input for every gpt-6 slug and the same reasoning presets as the
gpt-5.6 line; the presets omit "none" for gpt-5.5 too, which accepts it.

The Codex slug ladder moves into codex-slug-chain.ts and gains gpt-6-sol,
gpt-6.1-sol and gpt-6-luna. gpt-5.6-terra stays first as the slug verified
against the backend; the catalog names gpt-6-sol as its upgrade, and a
rotation retires a whole line at once, so the gpt-6 rungs follow before
terra's own siblings. The ladder test now holds every rung to a recognised
reasoning family with a real off and image input. The OpenAI presets offer
gpt-6.1-sol and gpt-6-luna.
This commit is contained in:
Marc-André Bombeck committed 2026-10-24 10:11:16 +02:00
1 parent 3d47c1e6b4
commit 8787d71d1c
15 files changed
+387 -158

No files matched your search

+12 -6
View File
@@ -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.
@@ -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,
+5 -2
View File
@@ -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",
@@ -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<OpenAIReasoningFamily>,
),
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",
@@ -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) => {
@@ -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();
}
});
});
@@ -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", () => {
@@ -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`.
+6 -50
View File
@@ -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<string>();
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".
+69
View File
@@ -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<string>();
const result: string[] = [];
for (const slug of merged) {
if (seen.has(slug)) continue;
seen.add(slug);
result.push(slug);
}
return result;
}
+8 -13
View File
@@ -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 {
+88
View File
@@ -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-<major>[.<minor>][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;
}
@@ -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();
+13 -11
View File
@@ -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. */
+21 -66
View File
@@ -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;