Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/browser-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <auto|text|zip>`: 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.
Expand Down
52 changes: 51 additions & 1 deletion src/browser/actions/modelSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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;
Expand All @@ -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());
Expand Down Expand Up @@ -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;
}
Expand Down
32 changes: 31 additions & 1 deletion src/cli/browserConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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() ?? "");
}
Expand All @@ -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;
}
Expand Down
14 changes: 14 additions & 0 deletions src/cli/options.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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";
}
Expand Down
12 changes: 12 additions & 0 deletions src/oracle/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KnownModelName, ModelConfig> = {
"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",
Expand Down
1 change: 1 addition & 0 deletions src/oracle/geminiModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const MODEL_ID_MAP: Record<ModelName, string> = {
"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",
Expand Down
10 changes: 8 additions & 2 deletions src/oracle/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);

Expand Down Expand Up @@ -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 },
);
}
Expand Down
1 change: 1 addition & 0 deletions src/oracle/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type TokenizerFn = (input: unknown, options?: Record<string, unknown>) => number;

export type KnownModelName =
| "gpt-6-astra"
| "gpt-5.6"
| "gpt-5.6-sol"
| "gpt-5.5"
Expand Down
26 changes: 26 additions & 0 deletions tests/browser/modelSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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" });
Expand Down
Loading