diff --git a/CHANGELOG.md b/CHANGELOG.md index 85137c12c..dd22ddbda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Browser: GPT-6 Astra as `gpt-6-astra` / `gpt-6-pro` / `gpt-6` (API alias `gpt-6-astra`). ChatGPT exposes it as the "Latest" radio of the advanced picker (Pro tier via the power slider, pill "6 Pro"), so a version-less target is now decided on the checked radio or that pill instead of the blank composer signal, which reported "already selected" while GPT-5.6 Sol was active. + ### Fixed - Azure: ignore generic base URLs during model metadata resolution as well as request dispatch, preventing OpenRouter catalog lookups with Azure credentials. diff --git a/docs/browser-mode.md b/docs/browser-mode.md index e9d4cff9f..381e99163 100644 --- a/docs/browser-mode.md +++ b/docs/browser-mode.md @@ -126,6 +126,7 @@ Notes: - `--browser-bundle-files`: bundle all resolved attachments into a single temp file before uploading (only used when uploads are enabled/selected). - `--browser-bundle-format `: choose the bundle format. `auto` uses a text bundle for text-only inputs and a byte-preserving ZIP when bundled inputs include raw files; `text` keeps the single Markdown-style text bundle; `zip` archives the original file bytes. ZIP bundle inputs are capped at 128 MiB because bundle creation is in-memory. - sqlite bindings: automatic rebuilds now require `ORACLE_ALLOW_SQLITE_REBUILD=1`. Without it, the CLI logs instructions instead of running `pnpm rebuild` on your behalf. +- `--model gpt-6-pro` (or `gpt-6`, `gpt-6-astra`, `latest`): GPT-6 Astra. ChatGPT shows it as the **Latest** model of the advanced picker rather than as a named entry; `gpt-6-pro` also selects the Pro power tier by default (composer pill "6 Pro"), and Oracle only reports it as selected when that radio is checked or the pill reads "6 …" (never "5.6 …"). - `--model`: the same GPT-5.6 aliases work in API and browser mode. Use `gpt-5.6` for the current GPT-5.6 default or `gpt-5.6-sol` to pin Sol; browser mode maps either alias to the `GPT-5.6 Sol` picker entry, while API mode sends the corresponding first-party OpenAI model ID. GPT-5.2 base, Instant, and Thinking aliases remain available through the API but browser mode rejects them because ChatGPT retired those picker entries. Legacy Pro aliases still resolve to the latest Pro picker target. - Live Chrome cookie copying is disabled by default. The recommended migration is `--browser-manual-login`, which keeps token rotation inside a dedicated persistent automation profile. To retain the old launcher behavior, pass `--browser-cookie-sync` or set `browser.cookieSync=true` in the user config; Oracle warns about the live-session invalidation risk. When enabled, cookie copy is mandatory—if Oracle cannot copy cookies, the run exits early. Oracle copies a small ChatGPT auth/Cloudflare allowlist to avoid oversized request headers; use `--browser-cookie-names` only when you need to override that set. - Attach-running mode is mutually exclusive with launcher-owned flags such as `--browser-manual-login`, `--browser-chrome-profile`, `--browser-cookie-path`, `--browser-hide-window`, `--browser-keep-browser`, and `--browser-port`. `--remote-chrome` is allowed in attach-running mode, but only as the local host:port hint used for metadata discovery and the endpoint fallback. `--browser-chrome-path` is accepted but ignored. diff --git a/src/browser/actions/modelSelection.ts b/src/browser/actions/modelSelection.ts index 202398414..bbf43adc6 100644 --- a/src/browser/actions/modelSelection.ts +++ b/src/browser/actions/modelSelection.ts @@ -232,6 +232,10 @@ function buildModelSelectionExpression( const hasToken = (value, token) => normalizeText(value).split(' ').includes(token); // Normalize every candidate token to keep fuzzy matching deterministic. const normalizedTarget = normalizeText(PRIMARY_LABEL); + // "Latest" (GPT-6 since 2026-09) is a radio in the advanced view whose composer pill reads + // "6 Pro" / "6 High"…, while GPT-5.6 Sol's reads "5.6 Pro". Declared up front: getResolvedLabel + // runs on the picker-less path before the selection helpers below are initialized. + const targetIsLatest = normalizedTarget === 'latest'; const normalizedTokens = Array.from(new Set([normalizedTarget, ...LABEL_TOKENS])) .map((token) => normalizeText(token)) .filter(Boolean); @@ -373,6 +377,14 @@ function buildModelSelectionExpression( }; const getButtonLabel = () => (findModelButton()?.textContent ?? '').trim(); + // With the picker closed the only evidence for "Latest" is the composer pill, so a version-less + // "latest" target must be decided on it: the blank composer signal would otherwise pass as + // "already selected" while GPT-5.6 Sol is active. Defined here, before getResolvedLabel, because + // the "current" strategy resolves the label before the selection helpers further down exist. + const latestButtonSelected = () => { + const label = normalizeText(getButtonLabel()); + return /^(chatgpt |gpt )?6(?![0-9 .]*[0-9])/.test(label) && !/(^| )5 6/.test(label); + }; const getComposerModelLabel = () => (document.querySelector(COMPOSER_MODEL_SIGNAL_SELECTOR)?.textContent ?? '').trim(); const readComposerModelSignal = () => normalizeText(getComposerModelLabel()); @@ -544,15 +556,35 @@ function buildModelSelectionExpression( if (wantsInstant) return label.includes('instant'); if (wantsThinking) return Boolean(desiredVersion) && !labelHasProWord(label); if (desiredVersion) return true; + // A version-less target ("Latest") must match the radio that is actually checked in the + // advanced view: the opener's text lists every radio label, so a substring test would + // report "Latest" as selected while GPT-5.6 Sol is the checked model. + const checkedAdvancedRadio = findCheckedAdvancedModelRadio(parentMenu); + if (checkedAdvancedRadio) { + const checkedLabel = normalizeText(checkedAdvancedRadio.textContent ?? ''); + return normalizedTokens.some((token) => token && checkedLabel === token); + } return normalizedTokens.some((token) => token && label.includes(token)); }; + const findCheckedAdvancedModelRadio = (menu = null) => { + const scope = menu || findUnifiedPickerMenu() || document; + return ( + scope?.querySelector?.( + '[data-testid="composer-model-picker-slider-advanced-view"] [role="menuitemradio"][aria-checked="true"]', + ) ?? null + ); + }; const getAdvancedModelLabel = () => { const opener = findModelSubmenuOpener(findUnifiedPickerMenu()); if (!opener) return ''; const raw = (opener.textContent ?? '').trim(); const normalized = normalizeText(pickerNodeLabel(opener)); const version = versionFromLabel(normalized); - if (!version) return raw; + if (!version) { + const checkedAdvancedRadio = findCheckedAdvancedModelRadio(findUnifiedPickerMenu()); + const checkedLabel = (checkedAdvancedRadio?.textContent ?? '').trim(); + return checkedLabel || raw; + } const [major, minor] = version.split('-'); const suffix = normalized.split(' ').includes('sol') ? ' Sol' : ''; return 'GPT-' + major + '.' + minor + suffix; @@ -566,6 +598,17 @@ function buildModelSelectionExpression( ); }; const getResolvedLabel = (observedOptionLabel = '') => { + if (targetIsLatest) { + const checkedAdvancedRadio = findCheckedAdvancedModelRadio(); + if (checkedAdvancedRadio) return (checkedAdvancedRadio.textContent ?? '').trim(); + // Picker closed: the pill ("6 Pro") is the evidence; report the radio's name so callers + // can compare against the requested target instead of the tier-suffixed pill text. + if (latestButtonSelected()) return 'Latest'; + const currentButtonLabel = getButtonLabel(); + if (currentButtonLabel) return currentButtonLabel; + // No picker button at all (e.g. the "current" strategy on a page that hides it): fall back + // to the generic composer/observed label resolution below. + } if (configuredSelectionMatchesTarget()) { const variant = getConfiguredVariantLabel(); const version = formatModelOptionLabel(getConfiguredVersionLabel()); @@ -707,6 +750,13 @@ function buildModelSelectionExpression( return COMPOSER_SIGNAL_INCLUDES.some((token) => token && signal.includes(token)); }; const activeSelectionMatchesTarget = () => { + if (targetIsLatest) { + const checkedAdvancedRadio = findCheckedAdvancedModelRadio(); + if (checkedAdvancedRadio) { + return normalizeText(checkedAdvancedRadio.textContent ?? '') === 'latest'; + } + return latestButtonSelected(); + } if (advancedModelSignalMatchesTarget()) { return true; } diff --git a/src/cli/browserConfig.ts b/src/cli/browserConfig.ts index 47fe96b9c..cfafcffc0 100644 --- a/src/cli/browserConfig.ts +++ b/src/cli/browserConfig.ts @@ -33,6 +33,10 @@ const CURRENT_CHATGPT_PRO_ALIASES = new Set([ // The browser label is passed to the model picker which fuzzy-matches against ChatGPT's UI. const BROWSER_MODEL_LABELS: [ModelName, string][] = [ // Most specific first (e.g., "gpt-5.2-thinking" before "gpt-5.2") + // GPT-6 (Astra) has no entry of its own in the ChatGPT picker: it is the "Latest" radio of the + // advanced view, and "GPT-6 Pro" is that radio with the power slider at Pro (composer pill "6 Pro"). + ["gpt-6-pro", "Latest"], + ["gpt-6-astra", "Latest"], ["gpt-5.6-sol", "GPT-5.6 Sol"], ["gpt-5.6", "GPT-5.6 Sol"], ["gpt-5.5-pro", "GPT-5.5"], @@ -106,6 +110,13 @@ export interface BrowserFlagOptions { export function normalizeChatGptModelForBrowser(model: ModelName): ModelName { const normalized = model.toLowerCase() as ModelName; + // Browser-only alias: gpt-6-pro keeps its name so the Pro tier default survives (label "Latest"). + if (isGpt6ProAlias(normalized)) { + return "gpt-6-pro" as ModelName; + } + if (isGpt6Alias(normalized)) { + return "gpt-6-astra"; + } if (!normalized.startsWith("gpt-") || normalized.includes("codex")) { return model; } @@ -139,6 +150,22 @@ export function normalizeChatGptModelForBrowser(model: ModelName): ModelName { return model; } +// Documented spellings only: gpt-6, gpt-6-astra, gpt-6-pro (plus their label forms such as +// "GPT-6 Pro") and "latest" map to ChatGPT's "Latest" model. Any other gpt-6-* id (gpt-6-codex, +// gpt-6-custom, ...) is not an alias and must pass through unchanged for custom/OpenRouter use. +const GPT6_ALIAS_PATTERN = /^gpt[-_ ]?6(?:[-_ ](?:astra|pro))?$/; +const GPT6_PRO_ALIAS_PATTERN = /^gpt[-_ ]?6[-_ ]pro$/; + +// The -pro alias also selects the Pro power tier by default (see resolveDefaultBrowserThinkingTime). +export function isGpt6Alias(model: string | undefined): boolean { + const normalized = model?.trim().toLowerCase() ?? ""; + return normalized === "latest" || GPT6_ALIAS_PATTERN.test(normalized); +} + +export function isGpt6ProAlias(model: string | undefined): boolean { + return GPT6_PRO_ALIAS_PATTERN.test(model?.trim().toLowerCase() ?? ""); +} + export function isCurrentChatGptProAlias(model: string | undefined): boolean { return CURRENT_CHATGPT_PRO_ALIASES.has(model?.trim().toLowerCase() ?? ""); } @@ -155,7 +182,10 @@ export function resolveDefaultBrowserThinkingTime({ const strategy = normalizeBrowserModelStrategy(modelStrategy) ?? DEFAULT_MODEL_STRATEGY; if (strategy !== "select") return undefined; const normalizedModel = normalizeChatGptModelForBrowser(model as ModelName); - return isCurrentChatGptProAlias(requestedModel ?? model) || normalizedModel === "gpt-5.5-pro" + return isCurrentChatGptProAlias(requestedModel ?? model) || + isGpt6ProAlias(requestedModel ?? model) || + normalizedModel === "gpt-6-pro" || + normalizedModel === "gpt-5.5-pro" ? "pro" : undefined; } diff --git a/src/cli/options.ts b/src/cli/options.ts index a358a4f70..10d701eeb 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -1,3 +1,4 @@ +import { isGpt6Alias, isGpt6ProAlias } from "./browserConfig.js"; import { InvalidArgumentError, type Command } from "commander"; import { parseDuration } from "../duration.js"; import path from "node:path"; @@ -228,6 +229,11 @@ export function resolveApiModel(modelValue: string): ModelName { if (normalized.includes("/")) { return normalized as ModelName; } + // gpt-6-pro is a ChatGPT browser tier (Latest + Pro), not an API slug; the API side runs + // gpt-6-astra and the browser keeps the Pro default through the requested model. + if (isGpt6Alias(normalized)) { + return "gpt-6-astra"; + } const gpt56Label = parseBrowserGpt56Label(normalized); if (gpt56Label?.variant.split(" ").includes("pro")) { throw new InvalidArgumentError( @@ -334,6 +340,14 @@ export function inferModelFromLabel(modelValue: string): ModelName { if (normalized.includes("/")) { return normalized as ModelName; } + // gpt-6 / gpt-6-pro / latest: ChatGPT's "Latest" model (GPT-6 Astra). The browser-only -pro alias + // is passed through so its Pro tier default survives (resolveDefaultBrowserThinkingTime). + if (isGpt6ProAlias(normalized)) { + return "gpt-6-pro" as ModelName; + } + if (isGpt6Alias(normalized)) { + return "gpt-6-astra"; + } if (normalized.includes("grok")) { return "grok-4.1"; } diff --git a/src/oracle/config.ts b/src/oracle/config.ts index 24e2b9f79..72f6b02bf 100644 --- a/src/oracle/config.ts +++ b/src/oracle/config.ts @@ -46,6 +46,18 @@ const countTokensAnthropic: TokenizerFn = (input: unknown): number => { const GPT_5_6_BASE_RATE_INPUT_LIMIT = 272_000; export const MODEL_CONFIGS: Record = { + "gpt-6-astra": { + model: "gpt-6-astra", + provider: "openai", + tokenizer: countTokensGpt5 as TokenizerFn, + // Same base-rate window as GPT-5.6: prompts above 272K input tokens are billed at the long-context multiplier. + inputLimit: GPT_5_6_BASE_RATE_INPUT_LIMIT, + pricing: { + inputPerToken: 10 / 1_000_000, + outputPerToken: 50 / 1_000_000, + }, + reasoning: { effort: "xhigh" }, + }, "gpt-5.6": { model: "gpt-5.6", provider: "openai", diff --git a/src/oracle/geminiModels.ts b/src/oracle/geminiModels.ts index b985ae489..24ebc1ba4 100644 --- a/src/oracle/geminiModels.ts +++ b/src/oracle/geminiModels.ts @@ -5,6 +5,7 @@ const MODEL_ID_MAP: Record = { "gemini-3.1-pro": "gemini-3.1-pro-preview", "gemini-3.5-flash": "gemini-3.5-flash", "gemini-3-pro": "gemini-3-pro-preview", + "gpt-6-astra": "gpt-6-astra", "gpt-5.6": "gpt-5.6", "gpt-5.6-sol": "gpt-5.6-sol", "gpt-5.5": "gpt-5.5", diff --git a/src/oracle/run.ts b/src/oracle/run.ts index 8583ee7a3..eb4a4a25b 100644 --- a/src/oracle/run.ts +++ b/src/oracle/run.ts @@ -60,7 +60,13 @@ const dim = (text: string): string => (isStdoutTty ? kleur.dim(text) : text); // Default timeout for non-pro API runs (fast models) — give them up to 120s. const DEFAULT_TIMEOUT_NON_PRO_MS = 120_000; const DEFAULT_TIMEOUT_PRO_MS = 60 * 60 * 1000; -const GPT_5_6_API_MODELS = new Set(["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); +const GPT_5_6_API_MODELS = new Set([ + "gpt-6-astra", + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", +]); const REASONING_EFFORTS = new Set(["none", "low", "medium", "high", "xhigh", "max"]); const REASONING_MODES = new Set(["standard", "pro"]); @@ -134,7 +140,7 @@ function validateReasoningOptions(options: RunOracleOptions, route: ResolvedProv ? `Use --model gpt-5.6-sol --reasoning-mode ${reasoningMode}.` : `Use --model gpt-5.6-sol --reasoning-effort ${reasoningEffort}.`; throw new PromptValidationError( - `${option} is available only for GPT-5.6 API models. ${guidance}`, + `${option} is available only for GPT-6 and GPT-5.6 API models. ${guidance}`, { model: options.model, reasoningEffort, reasoningMode }, ); } diff --git a/src/oracle/types.ts b/src/oracle/types.ts index b319c99c2..0ef8bfcbf 100644 --- a/src/oracle/types.ts +++ b/src/oracle/types.ts @@ -1,6 +1,7 @@ export type TokenizerFn = (input: unknown, options?: Record) => number; export type KnownModelName = + | "gpt-6-astra" | "gpt-5.6" | "gpt-5.6-sol" | "gpt-5.5" diff --git a/tests/browser/modelSelection.test.ts b/tests/browser/modelSelection.test.ts index 4503af042..9f8cd97d5 100644 --- a/tests/browser/modelSelection.test.ts +++ b/tests/browser/modelSelection.test.ts @@ -1304,6 +1304,20 @@ describe("browser model selection matchers", () => { ).toEqual({ status: "already-selected", label: "GPT-5.6 Sol" }); }); + it("accepts a GPT-6 composer pill as the selected Latest model", () => { + expect(evaluateImmediateModelSelectionExpression("Latest", "6 Pro")).toEqual({ + status: "already-selected", + label: "Latest", + }); + }); + + it("does not report Latest as selected while GPT-5.6 Sol is the active model", () => { + expect(evaluateImmediateModelSelectionExpression("Latest", "5.6 Pro")).toBeInstanceOf(Promise); + expect(evaluateImmediateModelSelectionExpression("Latest", "GPT-5.6 Sol")).toBeInstanceOf( + Promise, + ); + }); + it("includes real pointer coordinates when opening version submenus", () => { const expression = buildModelSelectionExpressionForTest("GPT-5.6 Sol"); expect(expression).toContain("rect.x + rect.width / 2"); @@ -1551,6 +1565,18 @@ describe("browser model selection matchers", () => { expect(result).toEqual({ status: "already-selected", label: "Thinking" }); }); + it("reports the composer pill for a Latest target under the current strategy", () => { + // No checked advanced radio and no picker button: must resolve on the pill without throwing. + expect(evaluateNoModelButtonExpression("Latest", "current", "6Pro")).toEqual({ + status: "already-selected", + label: "6Pro", + }); + expect(evaluateNoModelButtonExpression("Latest", "current")).toEqual({ + status: "already-selected", + label: null, + }); + }); + it("keeps strict selection failed when ChatGPT hides the model picker", () => { const result = evaluateNoModelButtonExpression("Pro", "select"); expect(result).toEqual({ status: "button-missing" }); diff --git a/tests/cli/browserConfig.test.ts b/tests/cli/browserConfig.test.ts index 80b91ed3d..1e8e48ee4 100644 --- a/tests/cli/browserConfig.test.ts +++ b/tests/cli/browserConfig.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test, vi } from "vitest"; -import { buildBrowserConfig, resolveBrowserModelLabel } from "../../src/cli/browserConfig.js"; +import { + buildBrowserConfig, + isGpt6Alias, + isGpt6ProAlias, + mapModelToBrowserLabel, + normalizeChatGptModelForBrowser, + resolveBrowserModelLabel, + resolveDefaultBrowserThinkingTime, +} from "../../src/cli/browserConfig.js"; describe("buildBrowserConfig", () => { test("uses defaults when optional flags omitted", async () => { @@ -69,6 +77,28 @@ describe("buildBrowserConfig", () => { expect(sol.desiredModel).toBe("GPT-5.6 Sol"); }); + test.each(["gpt-6", "gpt-6-astra", "latest"])( + "maps GPT-6 alias %s to the Latest picker target without a Pro default", + async (model) => { + const config = await buildBrowserConfig({ model }); + expect(config.desiredModel).toBe("Latest"); + expect(config.thinkingTime).toBeUndefined(); + }, + ); + + test("maps gpt-6-pro to the Latest picker target with Pro effort", async () => { + await expect(buildBrowserConfig({ model: "gpt-6-pro" })).resolves.toMatchObject({ + desiredModel: "Latest", + thinkingTime: "pro", + }); + await expect( + buildBrowserConfig({ model: "gpt-6-astra", browserRequestedModel: "gpt-6-pro" }), + ).resolves.toMatchObject({ + desiredModel: "Latest", + thinkingTime: "pro", + }); + }); + test("keeps version signal for gpt-5.5 Instant browser runs", async () => { const config = await buildBrowserConfig({ model: "gpt-5.5-instant" }); expect(config.desiredModel).toBe("GPT-5.5 Instant"); @@ -599,3 +629,56 @@ describe("resolveBrowserModelLabel", () => { ); }); }); + +describe("GPT-6 aliases", () => { + test("recognizes only the documented spellings", () => { + for (const alias of [ + "gpt-6", + "gpt-6-astra", + "gpt-6-pro", + "latest", + "GPT-6 Astra", + "GPT-6 Pro", + ]) { + expect(isGpt6Alias(alias), alias).toBe(true); + } + expect(isGpt6ProAlias("gpt-6-pro")).toBe(true); + expect(isGpt6ProAlias("GPT-6 Pro")).toBe(true); + expect(isGpt6ProAlias("gpt-6")).toBe(false); + expect(isGpt6ProAlias("gpt-6-astra")).toBe(false); + expect(isGpt6ProAlias("latest")).toBe(false); + }); + + test.each([ + "gpt-6-codex", + "gpt-6-custom", + "gpt-6-astra-mini", + "gpt-6-pro-max", + "gpt-6.1", + "gpt-60", + ])("does not treat %s as a GPT-6 alias", (model) => { + expect(isGpt6Alias(model)).toBe(false); + expect(isGpt6ProAlias(model)).toBe(false); + expect(normalizeChatGptModelForBrowser(model as never)).toBe(model); + }); + + test("normalizes the aliases for the browser and keeps gpt-6-pro as the browser-only alias", () => { + expect(normalizeChatGptModelForBrowser("gpt-6")).toBe("gpt-6-astra"); + expect(normalizeChatGptModelForBrowser("gpt-6-astra")).toBe("gpt-6-astra"); + expect(normalizeChatGptModelForBrowser("latest" as never)).toBe("gpt-6-astra"); + expect(normalizeChatGptModelForBrowser("gpt-6-pro" as never)).toBe("gpt-6-pro"); + expect(mapModelToBrowserLabel("gpt-6-astra")).toBe("Latest"); + expect(mapModelToBrowserLabel("gpt-6-pro" as never)).toBe("Latest"); + }); + + test("defaults the Pro tier only for gpt-6-pro", () => { + expect(resolveDefaultBrowserThinkingTime({ model: "gpt-6-pro" })).toBe("pro"); + expect( + resolveDefaultBrowserThinkingTime({ model: "gpt-6-astra", requestedModel: "gpt-6-pro" }), + ).toBe("pro"); + expect(resolveDefaultBrowserThinkingTime({ model: "gpt-6-astra" })).toBeUndefined(); + expect(resolveDefaultBrowserThinkingTime({ model: "gpt-6" })).toBeUndefined(); + expect(resolveDefaultBrowserThinkingTime({ model: "latest" })).toBeUndefined(); + expect(resolveDefaultBrowserThinkingTime({ model: "gpt-6-pro-max" })).toBeUndefined(); + }); +}); diff --git a/tests/cli/options.test.ts b/tests/cli/options.test.ts index 50c53d07f..540d89008 100644 --- a/tests/cli/options.test.ts +++ b/tests/cli/options.test.ts @@ -270,6 +270,24 @@ describe("resolveApiModel", () => { ); }); + test("maps the documented GPT-6 aliases to the gpt-6-astra API model", () => { + expect(resolveApiModel("gpt-6")).toBe("gpt-6-astra"); + expect(resolveApiModel("gpt-6-astra")).toBe("gpt-6-astra"); + expect(resolveApiModel("latest")).toBe("gpt-6-astra"); + expect(resolveApiModel("GPT-6 Pro")).toBe("gpt-6-astra"); + // Browser-only tier alias: the API side runs gpt-6-astra. + expect(resolveApiModel("gpt-6-pro")).toBe("gpt-6-astra"); + }); + + test("preserves unknown gpt-6-* ids verbatim (OpenRouter/custom)", () => { + expect(resolveApiModel("gpt-6-custom")).toBe("gpt-6-custom"); + expect(resolveApiModel("gpt-6-astra-mini")).toBe("gpt-6-astra-mini"); + expect(resolveApiModel("gpt-6.1")).toBe("gpt-6.1"); + expect(resolveApiModel("openai/gpt-6-astra")).toBe("openai/gpt-6-astra"); + // Not intercepted as an alias either: the pre-existing codex heuristic still owns this id. + expect(resolveApiModel("gpt-6-codex")).toBe("gpt-5.1-codex"); + }); + test("passes through unknown names (OpenRouter/custom)", () => { expect(resolveApiModel("instant")).toBe("instant"); expect(resolveApiModel("openai/gpt-5.4")).toBe("openai/gpt-5.4"); @@ -299,6 +317,24 @@ describe("inferModelFromLabel", () => { expect(inferModelFromLabel("ChatGPT 5_6")).toBe("gpt-5.6"); }); + test("infers the documented GPT-6 aliases and keeps the browser-only Pro alias", () => { + expect(inferModelFromLabel("gpt-6")).toBe("gpt-6-astra"); + expect(inferModelFromLabel("gpt-6-astra")).toBe("gpt-6-astra"); + expect(inferModelFromLabel("GPT-6 Astra")).toBe("gpt-6-astra"); + expect(inferModelFromLabel("latest")).toBe("gpt-6-astra"); + expect(inferModelFromLabel("Latest")).toBe("gpt-6-astra"); + expect(inferModelFromLabel("gpt-6-pro")).toBe("gpt-6-pro"); + expect(inferModelFromLabel("GPT-6 Pro")).toBe("gpt-6-pro"); + }); + + test("does not treat unknown gpt-6-* ids as the Latest alias", () => { + // Undeclared suffixes are not GPT-6 aliases: they follow the pre-existing label heuristics + // (codex -> gpt-5.1-codex, otherwise the generic fallback) instead of becoming gpt-6-astra. + expect(inferModelFromLabel("gpt-6-codex")).toBe("gpt-5.1-codex"); + expect(inferModelFromLabel("gpt-6-custom")).not.toMatch(/^gpt-6/); + expect(inferModelFromLabel("gpt-6-astra-mini")).not.toMatch(/^gpt-6/); + }); + test("does not reserve unrelated slashless API model ids containing 5.6", () => { expect(isGpt56BrowserLabel("vendor-5.6-large")).toBe(false); expect(isGpt56BrowserLabel("model_5_6_custom")).toBe(false);