mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
3d47c1e6b4
commit
8787d71d1c
15 files changed
+387
-158
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user