From 9c298112dc78b4dbf005775b4ee026dc5ffe38db Mon Sep 17 00:00:00 2001 From: euxaristia <25621994+euxaristia@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:44:08 -0400 Subject: [PATCH] feat(claude): add Opus 4.8 model support (#1769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(claude): add Opus 4.8 model support Adds Claude Opus 4.8 alongside 4.7 in the model registry, picker, pricing, integrations, and 1M-context support. Mirrors the established 4.7 pattern so longer suffixes resolve first in canonical-name matching. - configs: CLAUDE_OPUS_4_8_CONFIG + opus48 registry entry - model.ts: canonical resolver, default-model dispatch (1P -> 4.8, 3P bumped to 4.7), display & marketing names - modelOptions: getOpus48Option in PAYG 1P/3P, opusplan description - modelCost: COST_TIER_5_25 pricing - context: 1M-capable assertion - prompts: FRONTIER_MODEL_NAME -> Opus 4.8 - integrations: hicap gateway, nearai brand/vendor/model entries - claude brand catalog: new defineModel block Tests: extends modelSupports1M coverage for 4.8. * fix(models): gate Opus 4.8 out of PAYG 3P picker until rollout Opus 4.8 was being added to the third-party (3P) model picker while getDefaultOpusModel() keeps non-first-party usage on Opus 4.7. Remove the 3P option until 3P rollout is active; first-party picker is unaffected. Addresses CodeRabbit review on #1769. * test(integrations): cover NearAI anthropic/claude-opus-4-8 route Adds focused regression coverage for the new Opus 4.8 provider/model path: asserts the NearAI vendor catalog exposes the anthropic/claude-opus-4-8 entry and that it resolves through its modelDescriptorId to a registered NearAI model descriptor (vendor/brand nearai, correct default model + label), plus the NearAI route base URL. Addresses CodeRabbit's [Minor] request to test the exact route. * fix(models): wire Opus 4.8 into adaptive thinking, 3P fallback, and knowledge cutoff Addresses jatmn's review findings on #1769. - [High] thinking.ts: add opus-4-8 to the adaptive-thinking allowlist. Without it, claude-opus-4-8 hit the generic opus exclusion and returned false, dropping first-party Opus 4.8 into budget-based thinking instead of thinking: { type: 'adaptive' }. Adds a regression test (provider mocked to a non-1P value so the allowlist is the only reason 4.8 returns true). - [Medium] validateModel.ts: add an opus-4-8 -> opus47 entry to get3PFallbackSuggestion so an unavailable Opus 4.8 selection suggests 4.7. - [Medium] prompts.ts: getKnowledgeCutoff now returns "January 2026" for claude-opus-4-8 and claude-opus-4-7 instead of falling through to the stale generic "January 2025". - [Low] modelOptions.ts: update the PAYG 1P picker comment to include Opus 4.8. The betas.ts structured-outputs / auto-mode allowlists are intentionally left unchanged for this PR's scope (4.7 is also absent; auto mode is gated on PI safety probes) — to be revisited with safety-research before enabling. * fix(models): update remaining model-launch markers for Opus 4.8 default Addresses jatmn's follow-up review on #1769 — markers missed when Opus 4.8 became the default. - [P2] commitAttribution.ts: add explicit `opus-4-8` and `opus-4-7` branches to sanitizeModelName before the broad `opus-4` fallback, so commit/PR attribution shows the real model instead of `claude-opus-4`. Adds a focused regression test (commitAttribution.modelName.test.ts; mutation-checked). - [P2] attribution.ts: update the unknown-first-party-model co-author fallback from 'Claude Opus 4.6' to 'Claude Opus 4.8', and the matching test expectation. Also fixed a sibling de-dup test that was passing only by coincidence (it hit the 4.6 fallback): point it at a model the public-name map actually recognizes (dot form) so it exercises the real prefix-dedup path. - [P3] fastMode.ts: FAST_MODE_MODEL_DISPLAY 'Opus 4.6' -> 'Opus 4.8'. - [P3] context.test.ts: update the stale modelSupports1M test title/comment from Opus 4.7 to 4.8 (the current first-party default). * test(models): pin the claude-opus-4-7[1m] sanitizeModelName mapping too CodeRabbit follow-up on #1769: the test covered the suffixed 4.8 path but not the 4.7 branch with the same [1m] session suffix. Add the claude-opus-4-7[1m] case so both newly added mappings are pinned. * fix(models): extend fast-mode + default-effort gates to the current default Opus Addresses jatmn's review on #1769 — two predicates still gated to opus-4-6 only while the default Opus is now 4.8. - [P1] fastMode.ts: isFastModeSupportedByModel returned true only for opus-4-6, so for Max/Team Premium users on claude-opus-4-8 fast mode wouldn't actually enable even though FAST_MODE_MODEL_DISPLAY/the /fast command now say "Opus 4.8 only". Extend the predicate to the fast-mode-capable Opus models (4.8/4.7/4.6). - [P2] effort.ts: getDefaultEffortForModel applied the Pro/Max/Team `medium` default only for opus-4-6, so Pro/Max/Team sessions on the new default claude-opus-4-8 fell through to the generic effort path. Extend the branch to 4.8/4.7/4.6 (per the @[MODEL LAUNCH] marker). Adds regression tests for both (mutation-checked: reverting either predicate to opus-4-6 only fails them). * fix(models): wire Opus 4.8 into advisor, teammate fallback, skill vars, comments Addresses jatmn's follow-up model-launch markers on #1769. - [High] advisor.ts: modelSupportsAdvisor / isValidAdvisorModel only whitelisted opus-4-6 / sonnet-4-6, so first-party sessions on the new default claude-opus-4-8 reported the advisor tool unsupported. Add opus-4-8 and opus-4-7 to both (commands/advisor.ts and claude.ts use these centralized predicates, so they're covered). Adds a regression test (mutation-checked). - [Medium] swarm/teammateModel.ts: getHardcodedTeammateModelFallback hardcoded CLAUDE_OPUS_4_6_CONFIG -> CLAUDE_OPUS_4_8_CONFIG, so new teammates spawn on the current default. Adds a first-party test case (mutation-checked). - [Medium] skills/bundled/claudeApiContent.ts: SKILL_MODEL_VARS OPUS_ID/OPUS_NAME 4.6 -> 4.8 (the bundled claude-api skill docs don't hardcode 4.6 elsewhere). - [Low] effort.ts + figures.ts: refresh stale "max is Opus 4.6 only" comments to reflect the 4.8/4.7/4.6 runtime behavior. * test(swarm): assert provider-aware teammate fallback for Bedrock too CodeRabbit follow-up on #1769: add a non-first-party case so the provider-aware fallback is covered. Bedrock resolves to the Opus 4.8 Bedrock model id. * fix(models): give Opus 4.8/4.7 the elevated output-token limits and 3P fallback chain Addresses jatmn's review on #1769. - context.ts: getModelMaxOutputTokens only gave opus-4-6 the 64k/128k branch, so opus-4-7/4-8 fell through to the generic opus-4 branch and capped at 32k — including the new first-party default Opus 4.8. Extend the elevated branch to 4.8/4.7/4.6. Adds a regression test (mutation-checked). - errors.ts: get3PModelFallbackSuggestion had chains for opus-4-6/sonnet but not opus-4-8/4-7, so the error path suggested no fallback for the new default while validateModel.ts already does. Add opus-4-8 -> opus47 and opus-4-7 -> opus46 to mirror validateModel.ts. * fix(models): allow structured outputs on Opus 4.8/4.7 Addresses jatmn's finding #2 on #1769. modelSupportsStructuredOutputs whitelisted opus-4-1/4-5/4-6 but not 4-7/4-8, so first-party/Foundry requests on the new default Opus 4.8 lost the structured-output support that 4.6 had. Add claude-opus-4-7 and claude-opus-4-8 to the allowlist (4.6 supports it, so the newer Opus models do too). Adds a first-party regression test (mutation-checked). Auto-mode (modelSupportsExternalAutoMode) is intentionally left unchanged — it is gated on separate safety review and was not part of this finding. * fix(models): extend file-read mitigation exemption and effort callout to Opus 4.8/4.7 Addresses jatmn's remaining findings on #1769. - [P2] FileReadTool.ts: MITIGATION_EXEMPT_MODELS only held claude-opus-4-6, so the new default claude-opus-4-8 got the cyber-risk reminder appended to every file read that 4.6 did not — a behavioral regression. Add claude-opus-4-8 and claude-opus-4-7 so the recent Opus models inherit 4.6's exemption. - [P3] EffortCallout.tsx: shouldShowEffortCallout gated the medium-effort-default notification to opus-4-6 only; the same default now applies to opus-4-8, so users on the new default never saw it. Extend the gate to 4.8/4.7/4.6. Adds a regression test (mutation-checked). * fix(models): resolve Opus 4.6→4.8 drift in cost tracking, notifications, and picker strings Addresses jatmn's review on #1769 — remaining model-launch drift now that the first-party default is Opus 4.8. - [P1] modelCost.ts: getModelCosts only applied the elevated fast-mode tier to opus-4-6, so fast-mode Opus 4.8 was billed at the normal COST_TIER_5_25 rate while the picker advertised the fast-mode $30/$150 price. Extend the fast-mode cost check to the fast-mode-capable Opus models (4.8/4.7/4.6). Non-fast usage is unchanged (all three already map to COST_TIER_5_25). Adds a regression test (mutation-checked). - [P2] useModelMigrationNotifications.tsx: "Model updated to Opus 4.6" -> 4.8 (the migration lands users on the opus alias = 4.8 for first party). - [P2] commands/model/model.tsx: the 1M-unavailable error said "Opus 4.6"; made it generic ("Opus with 1M context...") since the gate matches any opus[1m]. - [P2] modelOptions.ts: getOpus46_1MOption is now provider-aware (3P → Opus 4.6, first-party → Opus 4.8); getMaxOpus46_1MOption (always first-party) → Opus 4.8. - [P3] migrateLegacyOpusToCurrent.ts: corrected the stale comment (opus alias resolves to 4.8, not 4.6). * docs(notifs): correct Opus default comment to 4.8 for 1P Comment said 4.6 but the migration notification text and the opus alias both resolve to Opus 4.8 for first-party users. Addresses jatmn P3 review note. * fix(integrations): remove duplicate claude-opus-4-8 descriptor A second claude-opus-4-8 entry (vendorId anthropic) with downgraded 200k/8192 specs duplicated the canonical 1M/128k descriptor. The artifact generator rejects duplicate (id, vendorId) pairs, so integrations:generate failed and smoke-and-tests could not pass. Removed the duplicate; the canonical entry and checked-in generated artifacts are unchanged. Addresses jatmn P1. * fix(models): address Opus 4.8 review — extra-usage label, callout test, stale copy - isBilledAsExtraUsage: recognize opus-4-7/4-8 1M variants, not just 4.6, so the "Billed as extra usage" label shows for the new default and 3P default - EffortCallout modelGate test: drop the unreliable `?ts=` cache-busting import and rely on mock.module live bindings, so the gate runs against the mocked deps on Linux CI (where the query-tagged specifier was not re-evaluated) - refresh stale "Opus 4.6+" effort help text and callout comments to reflect the recent Opus models (4.8/4.7/4.6) the gate now covers * test(effort): make Opus 4.8 callout regression deterministic via pure predicate The behavioral test mocked auth/config/effort and relied on the already-evaluated EffortCallout picking up those mocks, which is order-dependent and failed only in the full Linux CI suite (both the `?ts=` dynamic-import and the static-import variants regressed there). Extract the model check as a pure exported `effortCalloutCoversModel` and assert it directly with no module mocking, so the #1769 regression is covered deterministically on every platform. * test(effort): drop config-dependent 'opus' alias from callout regression test The bare 'opus' alias routes through getDefaultOpusModel(), whose result is environment/config-dependent, so `effortCalloutCoversModel('opus')` was false in a clean Linux CI environment even though the gate logic is correct — that single assertion was the only failure in smoke-and-tests (the explicit-id assertions passed). Assert the gate's opus-4-8/4-7/4-6 coverage with explicit canonical model ids (incl. a [1m] variant) instead, which is deterministic on every platform. --- src/commands/effort/effort.tsx | 2 +- src/commands/model/model.tsx | 2 +- .../EffortCallout.modelGate.test.ts | 22 +++++++ src/components/EffortCallout.tsx | 17 ++++- src/constants/figures.ts | 2 +- src/constants/prompts.ts | 9 ++- .../notifs/useModelMigrationNotifications.tsx | 4 +- src/integrations/brands/nearai.ts | 1 + src/integrations/models/nearai.ts | 11 ++++ src/integrations/nearai.test.ts | 62 +++++++++++++++++++ src/integrations/vendors/nearai.ts | 6 ++ src/migrations/migrateLegacyOpusToCurrent.ts | 2 +- src/screens/REPL.tsx | 2 +- src/services/api/errors.ts | 8 +++ src/skills/bundled/claudeApiContent.ts | 4 +- src/tools/FileReadTool/FileReadTool.ts | 10 ++- src/utils/advisor.modelGate.test.ts | 37 +++++++++++ src/utils/advisor.ts | 4 ++ src/utils/attribution.test.ts | 8 ++- src/utils/attribution.ts | 2 +- src/utils/betas.test.ts | 12 ++++ src/utils/betas.ts | 2 + src/utils/commitAttribution.modelName.test.ts | 17 +++++ src/utils/commitAttribution.ts | 2 + src/utils/config.ts | 2 +- src/utils/context.test.ts | 22 ++++++- src/utils/context.ts | 9 ++- src/utils/effort.test.ts | 55 ++++++++++++++++ src/utils/effort.ts | 15 +++-- src/utils/extraUsage.test.ts | 44 +++++++++++++ src/utils/extraUsage.ts | 12 +++- src/utils/fastMode.test.ts | 15 +++++ src/utils/fastMode.ts | 13 +++- src/utils/model/configs.ts | 17 +++++ src/utils/model/model.ts | 27 +++++--- src/utils/model/modelOptions.ts | 33 +++++++--- src/utils/model/validateModel.ts | 3 + src/utils/modelCost.modelGate.test.ts | 43 +++++++++++++ src/utils/modelCost.ts | 10 ++- src/utils/swarm/teammateModel.test.ts | 18 ++++++ src/utils/swarm/teammateModel.ts | 6 +- src/utils/thinking.test.ts | 15 +++++ src/utils/thinking.ts | 2 +- 43 files changed, 552 insertions(+), 57 deletions(-) create mode 100644 src/components/EffortCallout.modelGate.test.ts create mode 100644 src/integrations/nearai.test.ts create mode 100644 src/utils/advisor.modelGate.test.ts create mode 100644 src/utils/commitAttribution.modelName.test.ts create mode 100644 src/utils/effort.test.ts create mode 100644 src/utils/extraUsage.test.ts create mode 100644 src/utils/modelCost.modelGate.test.ts diff --git a/src/commands/effort/effort.tsx b/src/commands/effort/effort.tsx index aa7fde832..47fb90946 100644 --- a/src/commands/effort/effort.tsx +++ b/src/commands/effort/effort.tsx @@ -176,7 +176,7 @@ function ApplyEffortAndClose(t0) { export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise { args = args?.trim() || ''; if (COMMON_HELP_ARGS.includes(args)) { - onDone('Usage: /effort [low|medium|high|max|xhigh|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6+)\n- xhigh: Extra-high reasoning (OpenAI/Codex and Opus 4.7+)\n- auto: Use the default effort level for your model'); + onDone('Usage: /effort [low|medium|high|max|xhigh|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.8+)\n- xhigh: Extra-high reasoning (OpenAI/Codex and Opus 4.7+)\n- auto: Use the default effort level for your model'); return; } if (args === 'current' || args === 'status') { diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index 84e543ab1..70e1a44cd 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -855,7 +855,7 @@ function SetModelAndClose({ if (model && isOpus1mUnavailable(model)) { onDone( - 'Opus 4.6 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m', + 'Opus with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m', { display: 'system', }, diff --git a/src/components/EffortCallout.modelGate.test.ts b/src/components/EffortCallout.modelGate.test.ts new file mode 100644 index 000000000..b22b34b2f --- /dev/null +++ b/src/components/EffortCallout.modelGate.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from 'bun:test' +import { effortCalloutCoversModel } from './EffortCallout.js' + +// Regression for #1769: the default Opus is now 4.8, so the effort callout's +// model gate must cover opus-4-8 (and 4.7) alongside the original 4.6. This +// asserts the pure model predicate directly with explicit canonical model ids — +// no module mocking, no fresh-import, and no bare alias. The bare 'opus' alias +// is intentionally NOT used here: it routes through getDefaultOpusModel(), whose +// result is config/environment-dependent (it resolves the configured default +// Opus, not necessarily 4.8 in a clean CI environment), which is what made the +// earlier assertions flaky on Linux CI. The gate's own coverage logic is a plain +// string check, so explicit ids exercise the regression deterministically. +test('effort callout covers the recent Opus models including 4.8 (#1769)', () => { + expect(effortCalloutCoversModel('claude-opus-4-8')).toBe(true) + expect(effortCalloutCoversModel('claude-opus-4-7')).toBe(true) + expect(effortCalloutCoversModel('claude-opus-4-6')).toBe(true) + // The [1m] tag and provider-prefixed variants still match the family. + expect(effortCalloutCoversModel('claude-opus-4-8[1m]')).toBe(true) + // Models outside the recent-Opus family are not covered. + expect(effortCalloutCoversModel('claude-sonnet-4-6')).toBe(false) + expect(effortCalloutCoversModel('gpt-5')).toBe(false) +}) diff --git a/src/components/EffortCallout.tsx b/src/components/EffortCallout.tsx index aad9d330e..7fcf4001b 100644 --- a/src/components/EffortCallout.tsx +++ b/src/components/EffortCallout.tsx @@ -216,10 +216,21 @@ function EffortOptionLabel(t0) { * - Max/Team: getting medium via tengu_grey_step2 config; show when enabled * - Everyone else: mark as dismissed so it never shows */ +// Recent Opus models that get the medium-effort default and therefore the +// callout. The default Opus is now 4.8, so it must be covered alongside 4.7/4.6. +// Exported as a pure predicate so the regression can be tested deterministically +// without mocking the subscriber/config gates (see EffortCallout.modelGate.test). +export function effortCalloutCoversModel(model: string): boolean { + const parsed = parseUserSpecifiedModel(model).toLowerCase(); + return ( + parsed.includes('opus-4-8') || + parsed.includes('opus-4-7') || + parsed.includes('opus-4-6') + ); +} + export function shouldShowEffortCallout(model: string): boolean { - // Only show for Opus 4.6 for now - const parsed = parseUserSpecifiedModel(model); - if (!parsed.toLowerCase().includes('opus-4-6')) { + if (!effortCalloutCoversModel(model)) { return false; } const config = getGlobalConfig(); diff --git a/src/constants/figures.ts b/src/constants/figures.ts index cfb9a10d5..51ba6b615 100644 --- a/src/constants/figures.ts +++ b/src/constants/figures.ts @@ -12,7 +12,7 @@ export const LIGHTNING_BOLT = '↯' // \u21af - used for fast mode indicator export const EFFORT_LOW = '○' // \u25cb - effort level: low export const EFFORT_MEDIUM = '◐' // \u25d0 - effort level: medium export const EFFORT_HIGH = '●' // \u25cf - effort level: high -export const EFFORT_MAX = '◉' // \u25c9 - effort level: max (Opus 4.6 only) +export const EFFORT_MAX = '◉' // \u25c9 - effort level: max (Opus 4.8/4.7/4.6 only) // Media/trigger status indicators export const PLAY_ICON = '\u25b6' // ▶ diff --git a/src/constants/prompts.ts b/src/constants/prompts.ts index 724e8d5ef..53433cd2b 100644 --- a/src/constants/prompts.ts +++ b/src/constants/prompts.ts @@ -116,7 +116,7 @@ export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__' // @[MODEL LAUNCH]: Update the latest frontier model. -const FRONTIER_MODEL_NAME = 'Claude Opus 4.7' +const FRONTIER_MODEL_NAME = 'Claude Opus 4.8' function getHooksSection(): string { return `Users may configure 'hooks', shell commands that execute in response to events like tool calls, in settings. Treat feedback from hooks, including , as coming from the user. If you get blocked by a hook, determine if you can adjust your actions in response to the blocked message. If not, ask the user to check their hooks configuration.` @@ -705,7 +705,12 @@ export async function computeSimpleEnvInfo( // @[MODEL LAUNCH]: Add a knowledge cutoff date for the new model. function getKnowledgeCutoff(modelId: string): string | null { const canonical = getCanonicalName(modelId) - if (canonical.includes('claude-sonnet-4-6')) { + if ( + canonical.includes('claude-opus-4-8') || + canonical.includes('claude-opus-4-7') + ) { + return 'January 2026' + } else if (canonical.includes('claude-sonnet-4-6')) { return 'August 2025' } else if (canonical.includes('claude-opus-4-6')) { return 'May 2025' diff --git a/src/hooks/notifs/useModelMigrationNotifications.tsx b/src/hooks/notifs/useModelMigrationNotifications.tsx index 1e8fbee6f..5f6b3b71b 100644 --- a/src/hooks/notifs/useModelMigrationNotifications.tsx +++ b/src/hooks/notifs/useModelMigrationNotifications.tsx @@ -19,14 +19,14 @@ c => { }; }, // Opus Pro → default, or pinned 4.0/4.1 → opus alias. Both land on the -// current Opus default (4.6 for 1P). +// current Opus default (4.8 for 1P). c => { const isLegacyRemap = Boolean(c.legacyOpusMigrationTimestamp); const ts = c.legacyOpusMigrationTimestamp ?? c.opusProMigrationTimestamp; if (!recent(ts)) return; return { key: 'opus-pro-update', - text: isLegacyRemap ? 'Model updated to Opus 4.6 · Set CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP=1 to opt out' : 'Model updated to Opus 4.6', + text: isLegacyRemap ? 'Model updated to Opus 4.8 · Set CLAUDE_CODE_DISABLE_LEGACY_MODEL_REMAP=1 to opt out' : 'Model updated to Opus 4.8', color: 'suggestion', priority: 'high', timeoutMs: isLegacyRemap ? 8000 : 3000 diff --git a/src/integrations/brands/nearai.ts b/src/integrations/brands/nearai.ts index 625d53b7b..fd79a8c0a 100644 --- a/src/integrations/brands/nearai.ts +++ b/src/integrations/brands/nearai.ts @@ -12,6 +12,7 @@ export default defineBrand({ supportsPreciseTokenCount: false, }, modelIds: [ + 'anthropic/claude-opus-4-8', 'anthropic/claude-opus-4-7', 'anthropic/claude-opus-4-6', 'anthropic/claude-sonnet-4-6', diff --git a/src/integrations/models/nearai.ts b/src/integrations/models/nearai.ts index acc36e26f..f6db57235 100644 --- a/src/integrations/models/nearai.ts +++ b/src/integrations/models/nearai.ts @@ -2,6 +2,17 @@ import { defineModel } from '../define.js' export default [ // ── Anthropic (proxied) ── + defineModel({ + id: 'anthropic/claude-opus-4-8', + defaultModel: 'anthropic/claude-opus-4-8', + label: 'Claude Opus 4.8', + brandId: 'nearai', + vendorId: 'nearai', + classification: ['chat', 'coding'], + capabilities: { supportsVision: false, supportsStreaming: true, supportsFunctionCalling: true, supportsJsonMode: true, supportsReasoning: false, supportsPreciseTokenCount: false }, + contextWindow: 1000000, + maxOutputTokens: 65536, + }), defineModel({ id: 'anthropic/claude-opus-4-7', defaultModel: 'anthropic/claude-opus-4-7', diff --git a/src/integrations/nearai.test.ts b/src/integrations/nearai.test.ts new file mode 100644 index 000000000..927059d55 --- /dev/null +++ b/src/integrations/nearai.test.ts @@ -0,0 +1,62 @@ +// src/integrations/nearai.test.ts +// +// Regression coverage for the NearAI `anthropic/claude-opus-4-8` route. +// The vendor catalog exposes the model via a catalog entry whose +// modelDescriptorId must resolve to a registered model descriptor; this +// test exercises that exact provider/model path so the route can't silently +// break (e.g. a removed descriptor or a mismatched id) without failing here. + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +import { ensureIntegrationsLoaded } from './index.js' +import { + _clearRegistryForTesting, + getCatalogEntriesForRoute, + getModelsForVendor, +} from './registry.js' +import { getRouteDefaultBaseUrl } from './routeMetadata.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' + +const OPUS_48_ID = 'anthropic/claude-opus-4-8' + +beforeEach(async () => { + await acquireSharedMutationLock('integrations/nearai.test.ts') + _clearRegistryForTesting() + ensureIntegrationsLoaded() +}) + +afterEach(() => { + releaseSharedMutationLock() +}) + +describe('NearAI Claude Opus 4.8 route', () => { + test('catalog exposes the anthropic/claude-opus-4-8 entry', () => { + const entry = getCatalogEntriesForRoute('nearai').find( + e => e.id === OPUS_48_ID, + ) + expect(entry).toBeDefined() + expect(entry!.apiName).toBe(OPUS_48_ID) + // The entry must link to a model descriptor so the route resolves it. + expect(entry!.modelDescriptorId).toBe(OPUS_48_ID) + }) + + test('catalog entry resolves to a registered NearAI model descriptor', () => { + const model = getModelsForVendor('nearai').find(m => m.id === OPUS_48_ID) + // getModelsForVendor drops entries whose modelDescriptorId has no + // matching descriptor, so a defined result proves the route is wired. + expect(model).toBeDefined() + expect(model!.vendorId).toBe('nearai') + expect(model!.brandId).toBe('nearai') + expect(model!.defaultModel).toBe(OPUS_48_ID) + expect(model!.label).toBe('Claude Opus 4.8') + }) + + test('NearAI route points at the NearAI cloud gateway base URL', () => { + expect(getRouteDefaultBaseUrl('nearai')).toBe( + 'https://cloud-api.near.ai/v1', + ) + }) +}) diff --git a/src/integrations/vendors/nearai.ts b/src/integrations/vendors/nearai.ts index fa1692d9e..4d2f9baac 100644 --- a/src/integrations/vendors/nearai.ts +++ b/src/integrations/vendors/nearai.ts @@ -34,6 +34,12 @@ export default defineVendor({ source: 'static', models: [ // ── Anthropic (proxied) ── + { + id: 'anthropic/claude-opus-4-8', + apiName: 'anthropic/claude-opus-4-8', + label: 'Claude Opus 4.8', + modelDescriptorId: 'anthropic/claude-opus-4-8', + }, { id: 'anthropic/claude-opus-4-7', apiName: 'anthropic/claude-opus-4-7', diff --git a/src/migrations/migrateLegacyOpusToCurrent.ts b/src/migrations/migrateLegacyOpusToCurrent.ts index bdca4aada..8e7181c61 100644 --- a/src/migrations/migrateLegacyOpusToCurrent.ts +++ b/src/migrations/migrateLegacyOpusToCurrent.ts @@ -13,7 +13,7 @@ import { /** * Migrate first-party users off explicit Opus 4.0/4.1 model strings. * - * The 'opus' alias already resolves to Opus 4.6 for 1P, so anyone still + * The 'opus' alias already resolves to Opus 4.8 for 1P, so anyone still * on an explicit 4.0/4.1 string pinned it in settings before 4.5 launched. * parseUserSpecifiedModel now silently remaps these at runtime anyway — * this migration cleans up the settings file so /model shows the right diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 484fe4954..fd3442ed8 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -2245,7 +2245,7 @@ export function REPL({ // Onboarding dialogs (special conditions) if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding'; - // Effort callout (shown once for Opus 4.6 users when effort is enabled) + // Effort callout (shown once for recent Opus users when effort is enabled) if (allowDialogsWithAnimation && showEffortCallout) return 'effort-callout'; // Remote callout (shown once before first bridge enable) diff --git a/src/services/api/errors.ts b/src/services/api/errors.ts index b93289d76..39d98ef59 100644 --- a/src/services/api/errors.ts +++ b/src/services/api/errors.ts @@ -1241,6 +1241,14 @@ function get3PModelFallbackSuggestion(model: string): string | undefined { } // @[MODEL LAUNCH]: Add a fallback suggestion chain for the new model → previous version for 3P const m = model.toLowerCase() + // Mirror the validation-time fallback chain in validateModel.ts so the error + // path suggests the previous Opus for the recent models too. + if (m.includes('opus-4-8') || m.includes('opus_4_8')) { + return getModelStrings().opus47 + } + if (m.includes('opus-4-7') || m.includes('opus_4_7')) { + return getModelStrings().opus46 + } // If the failing model looks like an Opus 4.6 variant, suggest the default Opus (4.1 for 3P) if (m.includes('opus-4-6') || m.includes('opus_4_6')) { return getModelStrings().opus41 diff --git a/src/skills/bundled/claudeApiContent.ts b/src/skills/bundled/claudeApiContent.ts index cba76a3b3..03e2582ba 100644 --- a/src/skills/bundled/claudeApiContent.ts +++ b/src/skills/bundled/claudeApiContent.ts @@ -34,8 +34,8 @@ import typescriptClaudeApiToolUse from './claude-api/typescript/claude-api/tool- // - claude-api/SKILL.md (Current Models pricing table) // - claude-api/shared/models.md (full model catalog with legacy versions and alias mappings) export const SKILL_MODEL_VARS = { - OPUS_ID: 'claude-opus-4-6', - OPUS_NAME: 'Claude Opus 4.6', + OPUS_ID: 'claude-opus-4-8', + OPUS_NAME: 'Claude Opus 4.8', SONNET_ID: 'claude-sonnet-4-6', SONNET_NAME: 'Claude Sonnet 4.6', HAIKU_ID: 'claude-haiku-4-5', diff --git a/src/tools/FileReadTool/FileReadTool.ts b/src/tools/FileReadTool/FileReadTool.ts index 97d226549..2c199eaf8 100644 --- a/src/tools/FileReadTool/FileReadTool.ts +++ b/src/tools/FileReadTool/FileReadTool.ts @@ -817,8 +817,14 @@ function formatFileLines(file: { content: string; startLine: number }): string { export const CYBER_RISK_MITIGATION_REMINDER = '\n\n\nWhenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.\n\n' -// Models where cyber risk mitigation should be skipped -const MITIGATION_EXEMPT_MODELS = new Set(['claude-opus-4-6']) +// Models where cyber risk mitigation should be skipped. The recent Opus models +// (4.8/4.7) inherit 4.6's exemption — 4.8 is now the first-party default, so +// without this it would get the reminder on every file read that 4.6 did not. +const MITIGATION_EXEMPT_MODELS = new Set([ + 'claude-opus-4-8', + 'claude-opus-4-7', + 'claude-opus-4-6', +]) function shouldIncludeFileReadMitigation(): boolean { if (isEnvTruthy(process.env.OPENCLAUDE_DISABLE_TOOL_REMINDERS)) { diff --git a/src/utils/advisor.modelGate.test.ts b/src/utils/advisor.modelGate.test.ts new file mode 100644 index 000000000..3e73ef2e2 --- /dev/null +++ b/src/utils/advisor.modelGate.test.ts @@ -0,0 +1,37 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +import { modelSupportsAdvisor, isValidAdvisorModel } from './advisor.ts' + +const originalUserType = process.env.USER_TYPE + +beforeEach(() => { + // The advisor allowlist short-circuits to true for USER_TYPE=ant; force a + // non-ant value so the model gate is what's actually exercised. + process.env.USER_TYPE = 'external' +}) + +afterEach(() => { + if (originalUserType === undefined) delete process.env.USER_TYPE + else process.env.USER_TYPE = originalUserType +}) + +// Regression for #1769: the advisor allowlist only matched opus-4-6/sonnet-4-6, +// so first-party sessions on the new default claude-opus-4-8 reported the model +// did not support the advisor tool. +describe('advisor model gate covers the current default Opus', () => { + test('modelSupportsAdvisor allows recent Opus (4.8/4.7/4.6) and sonnet-4-6', () => { + expect(modelSupportsAdvisor('claude-opus-4-8')).toBe(true) + expect(modelSupportsAdvisor('claude-opus-4-7')).toBe(true) + expect(modelSupportsAdvisor('claude-opus-4-6')).toBe(true) + expect(modelSupportsAdvisor('claude-sonnet-4-6')).toBe(true) + expect(modelSupportsAdvisor('claude-opus-4-1')).toBe(false) + }) + + test('isValidAdvisorModel allows recent Opus (4.8/4.7/4.6) and sonnet-4-6', () => { + expect(isValidAdvisorModel('claude-opus-4-8')).toBe(true) + expect(isValidAdvisorModel('claude-opus-4-7')).toBe(true) + expect(isValidAdvisorModel('claude-opus-4-6')).toBe(true) + expect(isValidAdvisorModel('claude-sonnet-4-6')).toBe(true) + expect(isValidAdvisorModel('claude-opus-4-1')).toBe(false) + }) +}) diff --git a/src/utils/advisor.ts b/src/utils/advisor.ts index 54a2dfb30..e3a25a254 100644 --- a/src/utils/advisor.ts +++ b/src/utils/advisor.ts @@ -89,6 +89,8 @@ export function getExperimentAdvisorModels(): export function modelSupportsAdvisor(model: string): boolean { const m = model.toLowerCase() return ( + m.includes('opus-4-8') || + m.includes('opus-4-7') || m.includes('opus-4-6') || m.includes('sonnet-4-6') || process.env.USER_TYPE === 'ant' @@ -99,6 +101,8 @@ export function modelSupportsAdvisor(model: string): boolean { export function isValidAdvisorModel(model: string): boolean { const m = model.toLowerCase() return ( + m.includes('opus-4-8') || + m.includes('opus-4-7') || m.includes('opus-4-6') || m.includes('sonnet-4-6') || process.env.USER_TYPE === 'ant' diff --git a/src/utils/attribution.test.ts b/src/utils/attribution.test.ts index f0f099814..f8ebd89de 100644 --- a/src/utils/attribution.test.ts +++ b/src/utils/attribution.test.ts @@ -194,7 +194,7 @@ describe('getDefaultCommitCoAuthorName', () => { apiProvider: 'firstParty', isInternalRepo: false, }), - ).toBe('Claude Opus 4.6') + ).toBe('Claude Opus 4.8') }) it('sanitizes unknown internal Claude co-author names', () => { @@ -208,9 +208,13 @@ describe('getDefaultCommitCoAuthorName', () => { }) it('does not duplicate the Claude prefix for Claude model names', () => { + // Use a model the public-name map recognizes (it keys on dot form) so this + // exercises the real de-dup path — getPublicModelDisplayName already returns + // a "Claude …"-prefixed name — rather than coincidentally hitting the + // unknown-model fallback. expect( getDefaultCommitCoAuthorName({ - model: 'claude-opus-4-6', + model: 'claude-opus-4.6', apiProvider: 'firstParty', isInternalRepo: false, }), diff --git a/src/utils/attribution.ts b/src/utils/attribution.ts index 9ded7018d..492c3fac6 100644 --- a/src/utils/attribution.ts +++ b/src/utils/attribution.ts @@ -89,7 +89,7 @@ export function getDefaultCommitCoAuthorName({ // actual configured model instead of claiming Claude Opus. if (apiProvider === 'firstParty') { // @[MODEL LAUNCH]: Update this fallback when the default public Claude model changes. - return 'Claude Opus 4.6' + return 'Claude Opus 4.8' } const sanitizedModel = sanitizeCoAuthorNamePart(model) diff --git a/src/utils/betas.test.ts b/src/utils/betas.test.ts index 1e38e0ea5..9ce7e88c1 100644 --- a/src/utils/betas.test.ts +++ b/src/utils/betas.test.ts @@ -144,6 +144,18 @@ test('getMergedBetas returns a non-empty list for the firstParty provider', asyn expect(getMergedBetas(MODEL).length).toBeGreaterThan(0) }) +test('modelSupportsStructuredOutputs covers the recent Opus models (4.8/4.7/4.6) on firstParty (#1769)', async () => { + // No provider env set => firstParty. Pre-fix, 4.7/4.8 were absent from the + // allowlist, so first-party requests on the new default Opus 4.8 lost the + // structured-output support that 4.6 had. + const { modelSupportsStructuredOutputs } = await importFreshBetas() + expect(modelSupportsStructuredOutputs('claude-opus-4-8')).toBe(true) + expect(modelSupportsStructuredOutputs('claude-opus-4-7')).toBe(true) + expect(modelSupportsStructuredOutputs('claude-opus-4-6')).toBe(true) + // A model outside the allowlist stays false. + expect(modelSupportsStructuredOutputs('claude-3-opus')).toBe(false) +}) + test('getMergedBetas returns a non-empty list for the bedrock provider', async () => { process.env.CLAUDE_CODE_USE_BEDROCK = '1' const { getMergedBetas } = await importFreshBetas() diff --git a/src/utils/betas.ts b/src/utils/betas.ts index 1caab361a..f5f9636a6 100644 --- a/src/utils/betas.ts +++ b/src/utils/betas.ts @@ -152,6 +152,8 @@ export function modelSupportsStructuredOutputs(model: string): boolean { canonical.includes('claude-opus-4-1') || canonical.includes('claude-opus-4-5') || canonical.includes('claude-opus-4-6') || + canonical.includes('claude-opus-4-7') || + canonical.includes('claude-opus-4-8') || canonical.includes('claude-haiku-4-5') ) } diff --git a/src/utils/commitAttribution.modelName.test.ts b/src/utils/commitAttribution.modelName.test.ts new file mode 100644 index 000000000..868eef8dc --- /dev/null +++ b/src/utils/commitAttribution.modelName.test.ts @@ -0,0 +1,17 @@ +import { expect, test } from 'bun:test' + +import { sanitizeModelName } from './commitAttribution.ts' + +// Regression for #1769: opus-4-8 and opus-4-7 must map to their own public +// names, not fall through to the broad `claude-opus-4` branch (which would +// mislabel commit/PR attribution for first-party Opus 4.8/4.7 sessions). +test('sanitizeModelName maps Opus 4.8 and 4.7 to their public names', () => { + expect(sanitizeModelName('claude-opus-4-8')).toBe('claude-opus-4-8') + expect(sanitizeModelName('claude-opus-4-8[1m]')).toBe('claude-opus-4-8') + expect(sanitizeModelName('claude-opus-4-7')).toBe('claude-opus-4-7') + expect(sanitizeModelName('claude-opus-4-7[1m]')).toBe('claude-opus-4-7') + // Existing families still resolve correctly. + expect(sanitizeModelName('claude-opus-4-6')).toBe('claude-opus-4-6') + // A genuinely unknown opus-4 variant still falls back to the family name. + expect(sanitizeModelName('claude-opus-4-2')).toBe('claude-opus-4') +}) diff --git a/src/utils/commitAttribution.ts b/src/utils/commitAttribution.ts index 6cf8c4d03..a1489f5f6 100644 --- a/src/utils/commitAttribution.ts +++ b/src/utils/commitAttribution.ts @@ -153,6 +153,8 @@ export function sanitizeSurfaceKey(surfaceKey: string): string { */ export function sanitizeModelName(shortName: string): string { // Map internal variants to public equivalents based on model family + if (shortName.includes('opus-4-8')) return 'claude-opus-4-8' + if (shortName.includes('opus-4-7')) return 'claude-opus-4-7' if (shortName.includes('opus-4-6')) return 'claude-opus-4-6' if (shortName.includes('opus-4-5')) return 'claude-opus-4-5' if (shortName.includes('opus-4-1')) return 'claude-opus-4-1' diff --git a/src/utils/config.ts b/src/utils/config.ts index f28ea1d34..81ea0b019 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -475,7 +475,7 @@ export type GlobalConfig = { modelSwitchCalloutLastShown?: number // Timestamp of last shown (don't show for 24h) modelSwitchCalloutVersion?: string - // Effort callout tracking - shown once for Opus 4.6 users + // Effort callout tracking - shown once for recent Opus users (4.8/4.7/4.6) effortCalloutDismissed?: boolean // v1 - legacy, read to suppress v2 for Pro users who already saw it effortCalloutV2Dismissed?: boolean diff --git a/src/utils/context.test.ts b/src/utils/context.test.ts index 30ba1419c..90991613c 100644 --- a/src/utils/context.test.ts +++ b/src/utils/context.test.ts @@ -887,14 +887,30 @@ test('Anthropic model with high CLAUDE_CODE_MAX_OUTPUT_TOKENS still caps at mode expect(getMaxOutputTokensForModel('claude-3-opus')).toBe(4_096) }) -test('modelSupports1M recognizes the current default Opus (4.7) as 1M-capable', () => { +test('recent Opus models (4.8/4.7/4.6) get the elevated output-token limits (#1769)', () => { + // Regression: 4.8/4.7 used to fall through to the generic opus-4 branch and + // cap at 32k, while the default Opus is now 4.8. + const elevated = { default: 64_000, upperLimit: 128_000 } + expect(getModelMaxOutputTokens('claude-opus-4-8')).toEqual(elevated) + expect(getModelMaxOutputTokens('claude-opus-4-7')).toEqual(elevated) + expect(getModelMaxOutputTokens('claude-opus-4-6')).toEqual(elevated) + // Older Opus still capped lower. + expect(getModelMaxOutputTokens('claude-opus-4-1')).toEqual({ + default: 32_000, + upperLimit: 32_000, + }) +}) + +test('modelSupports1M recognizes the current default Opus (4.8) as 1M-capable', () => { const original = process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT delete process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT try { - // Regression: the firstParty default session model is claude-opus-4-7[1m] - // (getDefaultMainLoopModelSetting), so dropping 4.7 here downgrades a 1M + // Regression: the firstParty default session model is claude-opus-4-8[1m] + // (getDefaultMainLoopModelSetting), so dropping 4.8 here downgrades a 1M // session to 200K and trips a spurious "Context limit reached" — exactly // what resolveSkillModelOverride relies on this predicate to prevent. + expect(modelSupports1M('claude-opus-4-8')).toBe(true) + expect(modelSupports1M('claude-opus-4-8[1m]')).toBe(true) expect(modelSupports1M('claude-opus-4-7')).toBe(true) expect(modelSupports1M('claude-opus-4-7[1m]')).toBe(true) // Existing 1M models must keep working. diff --git a/src/utils/context.ts b/src/utils/context.ts index 9f580db0f..21b6ee01c 100644 --- a/src/utils/context.ts +++ b/src/utils/context.ts @@ -70,7 +70,8 @@ export function modelSupports1M(model: string): boolean { return ( canonical.includes('claude-sonnet-4') || canonical.includes('opus-4-6') || - canonical.includes('opus-4-7') + canonical.includes('opus-4-7') || + canonical.includes('opus-4-8') ) } @@ -290,7 +291,11 @@ export function getModelMaxOutputTokens(model: string): { const m = getCanonicalName(model) - if (m.includes('opus-4-6')) { + if ( + m.includes('opus-4-8') || + m.includes('opus-4-7') || + m.includes('opus-4-6') + ) { defaultTokens = 64_000 upperLimit = 128_000 } else if (m.includes('sonnet-4-6')) { diff --git a/src/utils/effort.test.ts b/src/utils/effort.test.ts new file mode 100644 index 000000000..4245b1ef4 --- /dev/null +++ b/src/utils/effort.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import * as realAuth from './auth.js' +import * as realThinking from './thinking.js' + +const originalEnv = { ...process.env } + +async function importFreshEffortModule() { + return import(`./effort.ts?ts=${Date.now()}-${Math.random()}`) +} + +beforeEach(async () => { + await acquireSharedMutationLock('utils/effort.test.ts') +}) + +afterEach(() => { + try { + mock.restore() + mock.module('./auth.js', () => realAuth) + mock.module('./thinking.js', () => realThinking) + process.env = { ...originalEnv } + } finally { + releaseSharedMutationLock() + } +}) + +describe('getDefaultEffortForModel — default-Opus effort gate (#1769)', () => { + test('Pro sessions on the default Opus (now 4.8) get medium effort', async () => { + process.env.USER_TYPE = 'external' + mock.module('./auth.js', () => ({ + ...realAuth, + isProSubscriber: () => true, + isMaxSubscriber: () => false, + isTeamSubscriber: () => false, + })) + // Keep the ultrathink path out of the way so the opus branch is what's tested. + mock.module('./thinking.js', () => ({ + ...realThinking, + isUltrathinkEnabled: () => false, + })) + + const { getDefaultEffortForModel } = await importFreshEffortModule() + + // Pre-fix this returned undefined because the branch only matched opus-4-6. + expect(getDefaultEffortForModel('claude-opus-4-8')).toBe('medium') + expect(getDefaultEffortForModel('claude-opus-4-7')).toBe('medium') + expect(getDefaultEffortForModel('claude-opus-4-6')).toBe('medium') + // Control: a non-default Opus does NOT get the medium default (proves the + // result comes from the model match, not isProSubscriber alone). + expect(getDefaultEffortForModel('claude-opus-4-1')).toBeUndefined() + }) +}) diff --git a/src/utils/effort.ts b/src/utils/effort.ts index 10e62ef8e..94d47a4f7 100644 --- a/src/utils/effort.ts +++ b/src/utils/effort.ts @@ -641,7 +641,8 @@ export function resolveOpenAIShimReasoningRequestPlan(options: { } } // @[MODEL LAUNCH]: Add the new model to the allowlist if it supports 'max' effort. -// Per API docs, 'max' is Opus 4.6 only for public models — other models return an error. +// Per API docs, 'max' is supported on the recent Opus models (4.8/4.7/4.6) for +// public models — other models return an error. function legacyModelSupportsMaxEffort(model: string): boolean { const supported3P = get3PModelCapabilityOverride(model, 'max_effort') if (supported3P !== undefined) { @@ -979,7 +980,7 @@ export function getEffortLevelDescription(level: EffortLevel | OpenAIEffortLevel case 'high': return 'Comprehensive implementation with extensive testing and documentation' case 'max': - return 'Maximum capability with deepest reasoning (Opus 4.6+)' + return 'Maximum capability with deepest reasoning (Opus 4.8+)' case 'xhigh': return 'Extra high reasoning effort for complex tasks' } @@ -1056,9 +1057,15 @@ function getLegacyDefaultEffortForModel( // the model launch DRI and research. Default effort is a sensitive setting // that can greatly affect model quality and bashing. - // Default effort on Opus 4.6 to medium for Pro. + // Default effort on the recent Opus models (4.8/4.7/4.6) to medium for Pro. // Max/Team also get medium when the tengu_grey_step2 config is enabled. - if (model.toLowerCase().includes('opus-4-6')) { + // getDefaultOpusModel() now returns opus48 for first-party users. + const lowerModel = model.toLowerCase() + if ( + lowerModel.includes('opus-4-8') || + lowerModel.includes('opus-4-7') || + lowerModel.includes('opus-4-6') + ) { if (isProSubscriber()) { return 'medium' } diff --git a/src/utils/extraUsage.test.ts b/src/utils/extraUsage.test.ts new file mode 100644 index 000000000..2c0b88668 --- /dev/null +++ b/src/utils/extraUsage.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import * as realAuth from './auth.js' +import { isBilledAsExtraUsage } from './extraUsage.js' + +beforeEach(async () => { + await acquireSharedMutationLock('utils/extraUsage.test.ts') + delete process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT + mock.module('./auth.js', () => ({ + ...realAuth, + isClaudeAISubscriber: () => true, + })) +}) + +afterEach(() => { + try { + mock.restore() + mock.module('./auth.js', () => realAuth) + } finally { + releaseSharedMutationLock() + } +}) + +// Regression for #1769: the default Opus is now 4.8 (4.7 is the 3P default), so +// the extra-usage label must cover opus-4-8/4-7 1M variants, not just 4.6. +test('1M Opus 4.8/4.7 variants are billed as extra usage', () => { + expect(isBilledAsExtraUsage('claude-opus-4-8[1m]', false, false)).toBe(true) + expect(isBilledAsExtraUsage('claude-opus-4-7[1m]', false, false)).toBe(true) + expect(isBilledAsExtraUsage('opus[1m]', false, false)).toBe(true) + expect(isBilledAsExtraUsage('claude-opus-4-6[1m]', false, false)).toBe(true) +}) + +test('1M Opus is not billed as extra when the Opus 1M merge is enabled', () => { + expect(isBilledAsExtraUsage('claude-opus-4-8[1m]', false, true)).toBe(false) + expect(isBilledAsExtraUsage('claude-opus-4-7[1m]', false, true)).toBe(false) + expect(isBilledAsExtraUsage('opus[1m]', false, true)).toBe(false) +}) + +test('non-1M models are not billed as extra usage', () => { + expect(isBilledAsExtraUsage('claude-opus-4-8', false, false)).toBe(false) +}) diff --git a/src/utils/extraUsage.ts b/src/utils/extraUsage.ts index b09968416..8dbdc368d 100644 --- a/src/utils/extraUsage.ts +++ b/src/utils/extraUsage.ts @@ -14,10 +14,16 @@ export function isBilledAsExtraUsage( .toLowerCase() .replace(/\[1m\]$/, '') .trim() - const isOpus46 = m === 'opus' || m.includes('opus-4-6') + // Keep this in sync with the Opus families modelSupports1M recognizes — the + // first-party default is now claude-opus-4-8, and 4.7 is the 3P default. + const isOpus = + m === 'opus' || + m.includes('opus-4-6') || + m.includes('opus-4-7') || + m.includes('opus-4-8') const isSonnet46 = m === 'sonnet' || m.includes('sonnet-4-6') - if (isOpus46 && isOpus1mMerged) return false + if (isOpus && isOpus1mMerged) return false - return isOpus46 || isSonnet46 + return isOpus || isSonnet46 } diff --git a/src/utils/fastMode.test.ts b/src/utils/fastMode.test.ts index c59135680..882f9c7f1 100644 --- a/src/utils/fastMode.test.ts +++ b/src/utils/fastMode.test.ts @@ -243,6 +243,21 @@ afterEach(async () => { } }) +describe('isFastModeSupportedByModel — Opus model gate (#1769)', () => { + test('supports the current default Opus (now 4.8), matching the /fast UI', async () => { + forceFirstPartyProviderEnv() + await installCommonMocks({ cachedEnabled: true, oauthToken: 'tok' }) + const { isFastModeSupportedByModel } = await importFreshFastModeModule() + await prepareFastModeTestState() + + // The 'opus' alias resolves to getDefaultOpusModel() = claude-opus-4-8 for + // first-party. The predicate must recognize it, or the "/fast" UI ("Opus + // 4.8 only") and runtime behavior disagree. (Pre-fix this returned false + // because the predicate only matched opus-4-6.) + expect(isFastModeSupportedByModel('opus')).toBe(true) + }) +}) + describe('fastMode ant-only fallback cleanup', () => { test('resolveFastModeStatusFromCache does not force-enable from USER_TYPE=ant', async () => { process.env.USER_TYPE = 'ant' diff --git a/src/utils/fastMode.ts b/src/utils/fastMode.ts index 4e1ce6868..041b657ef 100644 --- a/src/utils/fastMode.ts +++ b/src/utils/fastMode.ts @@ -140,7 +140,7 @@ export function getFastModeUnavailableReason(): string | null { } // @[MODEL LAUNCH]: Update supported Fast Mode models. -export const FAST_MODE_MODEL_DISPLAY = 'Opus 4.6' +export const FAST_MODE_MODEL_DISPLAY = 'Opus 4.8' export function getFastModeModel(): string { return 'opus' + (isOpus1mMergeEnabled() ? '[1m]' : '') @@ -171,8 +171,15 @@ export function isFastModeSupportedByModel( return false } const model = modelSetting ?? getDefaultMainLoopModelSetting() - const parsedModel = parseUserSpecifiedModel(model) - return parsedModel.toLowerCase().includes('opus-4-6') + const parsedModel = parseUserSpecifiedModel(model).toLowerCase() + // Fast mode is available on the recent Opus models (4.8/4.7/4.6); the default + // Opus is now 4.8, so the predicate must match it or the UI ("Opus 4.8 only") + // and runtime behavior disagree for Max/Team Premium users. + return ( + parsedModel.includes('opus-4-8') || + parsedModel.includes('opus-4-7') || + parsedModel.includes('opus-4-6') + ) } // --- Fast mode runtime state --- diff --git a/src/utils/model/configs.ts b/src/utils/model/configs.ts index 02dd1101c..0fb20a284 100644 --- a/src/utils/model/configs.ts +++ b/src/utils/model/configs.ts @@ -211,6 +211,22 @@ export const CLAUDE_OPUS_4_7_CONFIG = { xai: 'grok-4.3', } as const satisfies LegacyProviderModelConfig +export const CLAUDE_OPUS_4_8_CONFIG = { + firstParty: 'claude-opus-4-8', + bedrock: 'us.anthropic.claude-opus-4-8-v1', + vertex: 'claude-opus-4-8', + foundry: 'claude-opus-4-8', + openai: 'gpt-4o', + gemini: 'gemini-2.5-pro', + mistral: 'devstral-latest', + github: 'github:copilot', + codex: 'gpt-5.5', + 'nvidia-nim': 'nvidia/llama-3.1-nemotron-70b-instruct', + minimax: 'MiniMax-M2.5', + 'xiaomi-mimo': 'mimo-v2.5-pro', + xai: 'grok-4.3', +} as const satisfies LegacyProviderModelConfig + export const CLAUDE_SONNET_4_6_CONFIG = { firstParty: 'claude-sonnet-4-6', bedrock: 'us.anthropic.claude-sonnet-4-6', @@ -241,6 +257,7 @@ export const LEGACY_PROVIDER_MODEL_CONFIGS = { opus45: CLAUDE_OPUS_4_5_CONFIG, opus46: CLAUDE_OPUS_4_6_CONFIG, opus47: CLAUDE_OPUS_4_7_CONFIG, + opus48: CLAUDE_OPUS_4_8_CONFIG, } as const satisfies Record // Backward-compatible alias for existing imports. diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index 7bf21e7ca..fc6b3743d 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -95,7 +95,8 @@ export function isNonCustomOpusModel(model: ModelName): boolean { model === getModelStrings().opus41 || model === getModelStrings().opus45 || model === getModelStrings().opus46 || - model === getModelStrings().opus47 + model === getModelStrings().opus47 || + model === getModelStrings().opus48 ) } @@ -223,11 +224,11 @@ export function getDefaultOpusModel(): ModelName { } // 3P providers (Bedrock, Vertex, Foundry) — kept as a separate branch // since 3P availability lags firstParty and these will diverge again at - // the next model launch. Keep 3P on Opus 4.6 until they roll out 4.7. + // the next model launch. Keep 3P on Opus 4.7 until they roll out 4.8. if (getAPIProvider() !== 'firstParty') { - return getModelStrings().opus46 + return getModelStrings().opus47 } - return getModelStrings().opus47 + return getModelStrings().opus48 } // @[MODEL LAUNCH]: Update the default Sonnet model (3P providers may lag so keep defaults unchanged). @@ -444,7 +445,10 @@ export function getDefaultMainLoopModel(): ModelName { export function firstPartyNameToCanonical(name: ModelName): ModelShortName { name = name.toLowerCase() // Special cases for Claude 4+ models to differentiate versions - // Order matters: check more specific versions first (4-7 before 4-6 before 4-5 before 4) + // Order matters: check more specific versions first (4-8 before 4-7 before 4-6 before 4-5 before 4) + if (name.includes('claude-opus-4-8')) { + return 'claude-opus-4-8' + } if (name.includes('claude-opus-4-7')) { return 'claude-opus-4-7' } @@ -518,9 +522,9 @@ export function getClaudeAiUserDefaultModelDescription( ): string { if (isMaxSubscriber() || isTeamPremiumSubscriber()) { if (isOpus1mMergeEnabled()) { - return `Opus 4.7 with 1M context · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}` + return `Opus 4.8 with 1M context · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}` } - return `Opus 4.7 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}` + return `Opus 4.8 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}` } return 'Sonnet 4.6 · Best for everyday tasks' } @@ -529,7 +533,7 @@ export function renderDefaultModelSetting( setting: ModelName | ModelAlias, ): string { if (setting === 'opusplan') { - return 'Opus 4.7 in plan mode, else Sonnet 4.6' + return 'Opus 4.8 in plan mode, else Sonnet 4.6' } return renderModelName(parseUserSpecifiedModel(setting)) } @@ -633,6 +637,10 @@ export function getPublicModelDisplayName(model: ModelName): string | null { return 'GPT-5.4' case 'gpt-5.3-codex-spark': return 'GPT-5.3 Codex Spark' + case getModelStrings().opus48 + '[1m]': + return 'Opus 4.8 (1M context)' + case getModelStrings().opus48: + return 'Opus 4.8' case getModelStrings().opus47 + '[1m]': return 'Opus 4.7 (1M context)' case getModelStrings().opus47: @@ -887,6 +895,9 @@ export function getMarketingNameForModel(modelId: string): string | undefined { const has1m = modelId.toLowerCase().includes('[1m]') const canonical = getCanonicalName(modelId) + if (canonical.includes('claude-opus-4-8')) { + return has1m ? 'Opus 4.8 (with 1M context)' : 'Opus 4.8' + } if (canonical.includes('claude-opus-4-7')) { return has1m ? 'Opus 4.7 (with 1M context)' : 'Opus 4.7' } diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index fa33d1304..597452eb3 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -174,6 +174,16 @@ function getOpus41Option(): ModelOption { } } +function getOpus48Option(fastMode = false): ModelOption { + const is3P = getAPIProvider() !== 'firstParty' + return { + value: is3P ? getModelStrings().opus48 : 'opus', + label: 'Opus', + description: `Opus 4.8 · Most capable for complex work${getOpus46PricingSuffix(fastMode)}`, + descriptionForModel: 'Opus 4.8 - most capable for complex work', + } +} + function getOpus47Option(fastMode = false): ModelOption { const is3P = getAPIProvider() !== 'firstParty' return { @@ -207,12 +217,14 @@ export function getSonnet46_1MOption(): ModelOption { export function getOpus46_1MOption(fastMode = false): ModelOption { const is3P = getAPIProvider() !== 'firstParty' + // 3P pins Opus 4.6; first-party resolves the `opus` alias to the current + // default (Opus 4.8), so the label must follow the provider. + const opusName = is3P ? 'Opus 4.6' : 'Opus 4.8' return { value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]', label: 'Opus (1M context)', - description: `Opus 4.6 for long sessions${getOpus46PricingSuffix(fastMode)}`, - descriptionForModel: - 'Opus 4.6 with 1M context window - for long sessions with large codebases', + description: `${opusName} for long sessions${getOpus46PricingSuffix(fastMode)}`, + descriptionForModel: `${opusName} with 1M context window - for long sessions with large codebases`, } } @@ -266,7 +278,7 @@ function getMaxOpusOption(fastMode = false): ModelOption { return { value: 'opus', label: 'Opus', - description: `Opus 4.7 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`, + description: `Opus 4.8 · Most capable for complex work${fastMode ? getOpus46PricingSuffix(true) : ''}`, } } @@ -285,7 +297,7 @@ export function getMaxOpus46_1MOption(fastMode = false): ModelOption { return { value: 'opus[1m]', label: 'Opus (1M context)', - description: `Opus 4.6 with 1M context${billingInfo}${getOpus46PricingSuffix(fastMode)}`, + description: `Opus 4.8 with 1M context${billingInfo}${getOpus46PricingSuffix(fastMode)}`, } } @@ -294,9 +306,9 @@ function getMergedOpus1MOption(fastMode = false): ModelOption { return { value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]', label: 'Opus (1M context)', - description: `${is3P ? 'Opus 4.6' : 'Opus 4.7'} with 1M context · Most capable for complex work${!is3P && fastMode ? getOpus46PricingSuffix(fastMode) : ''}`, + description: `${is3P ? 'Opus 4.6' : 'Opus 4.8'} with 1M context · Most capable for complex work${!is3P && fastMode ? getOpus46PricingSuffix(fastMode) : ''}`, descriptionForModel: - `${is3P ? 'Opus 4.6' : 'Opus 4.7'} with 1M context - most capable for complex work`, + `${is3P ? 'Opus 4.6' : 'Opus 4.8'} with 1M context - most capable for complex work`, } } @@ -316,7 +328,7 @@ function getOpusPlanOption(): ModelOption { return { value: 'opusplan', label: 'Opus Plan Mode', - description: 'Use Opus 4.7 in plan mode, Sonnet 4.6 otherwise', + description: 'Use Opus 4.8 in plan mode, Sonnet 4.6 otherwise', } } @@ -554,7 +566,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { } } - // PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.7 + Opus 4.6 + Opus 1M + Haiku + // PAYG 1P API: Default (Sonnet) + Sonnet 1M + Opus 4.8 + Opus 4.7 + Opus 4.6 + Opus 1M + Haiku if (getAPIProvider() === 'firstParty') { const payg1POptions = [getDefaultOptionForUser(fastMode)] if (checkSonnet1mAccess()) { @@ -563,6 +575,7 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { if (isOpus1mMergeEnabled()) { payg1POptions.push(getMergedOpus1MOption(fastMode)) } else { + payg1POptions.push(getOpus48Option(fastMode)) payg1POptions.push(getOpus47Option(fastMode)) payg1POptions.push(getOpus46Option(fastMode)) if (checkOpus1mAccess()) { @@ -598,6 +611,8 @@ function getModelOptionsBase(fastMode = false): ModelOption[] { payg3pOptions.push(customOpus) } else { // Add Opus 4.1, Opus 4.7, Opus 4.6 and Opus 4.6 1M + // Opus 4.8 is intentionally omitted here until 3P rollout is active; + // getDefaultOpusModel() keeps non-first-party usage on Opus 4.7. payg3pOptions.push(getOpus41Option()) // This is the default opus payg3pOptions.push(getOpus47Option(fastMode)) payg3pOptions.push(getOpus46Option(fastMode)) diff --git a/src/utils/model/validateModel.ts b/src/utils/model/validateModel.ts index c4606cf96..197d0f42b 100644 --- a/src/utils/model/validateModel.ts +++ b/src/utils/model/validateModel.ts @@ -222,6 +222,9 @@ function get3PFallbackSuggestion(model: string): string | undefined { return undefined } const lowerModel = model.toLowerCase() + if (lowerModel.includes('opus-4-8') || lowerModel.includes('opus_4_8')) { + return getModelStrings().opus47 + } if (lowerModel.includes('opus-4-7') || lowerModel.includes('opus_4_7')) { return getModelStrings().opus46 } diff --git a/src/utils/modelCost.modelGate.test.ts b/src/utils/modelCost.modelGate.test.ts new file mode 100644 index 000000000..a532e0d27 --- /dev/null +++ b/src/utils/modelCost.modelGate.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, expect, mock, test } from 'bun:test' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import * as realFastMode from './fastMode.js' + +async function importFreshModelCost() { + return import(`./modelCost.js?ts=${Date.now()}-${Math.random()}`) +} + +beforeEach(async () => { + await acquireSharedMutationLock('utils/modelCost.modelGate.test.ts') +}) + +afterEach(() => { + try { + mock.restore() + mock.module('./fastMode.js', () => realFastMode) + } finally { + releaseSharedMutationLock() + } +}) + +// Regression for #1769: fast mode is now enabled for Opus 4.8, but getModelCosts +// only applied the elevated fast-mode tier to opus-4-6, so fast-mode 4.8 was +// billed at the normal rate while the picker advertised the fast-mode price. +test('fast-mode Opus 4.8 is charged the elevated fast-mode tier, normal otherwise', async () => { + mock.module('./fastMode.js', () => ({ + ...realFastMode, + isFastModeEnabled: () => true, + })) + const { getModelCosts, COST_TIER_30_150, COST_TIER_5_25 } = + await importFreshModelCost() + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fast = { speed: 'fast' } as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const standard = { speed: 'standard' } as any + + expect(getModelCosts('claude-opus-4-8', fast)).toEqual(COST_TIER_30_150) + expect(getModelCosts('claude-opus-4-8', standard)).toEqual(COST_TIER_5_25) +}) diff --git a/src/utils/modelCost.ts b/src/utils/modelCost.ts index 7089ef655..ea4bdedcd 100644 --- a/src/utils/modelCost.ts +++ b/src/utils/modelCost.ts @@ -12,6 +12,7 @@ import { CLAUDE_OPUS_4_5_CONFIG, CLAUDE_OPUS_4_6_CONFIG, CLAUDE_OPUS_4_7_CONFIG, + CLAUDE_OPUS_4_8_CONFIG, CLAUDE_OPUS_4_CONFIG, CLAUDE_SONNET_4_5_CONFIG, CLAUDE_SONNET_4_6_CONFIG, @@ -126,6 +127,8 @@ export const MODEL_COSTS: Record = { COST_TIER_5_25, [firstPartyNameToCanonical(CLAUDE_OPUS_4_7_CONFIG.firstParty)]: COST_TIER_5_25, + [firstPartyNameToCanonical(CLAUDE_OPUS_4_8_CONFIG.firstParty)]: + COST_TIER_5_25, } /** @@ -147,8 +150,13 @@ function tokensToUSDCost(modelCosts: ModelCosts, usage: Usage): number { export function getModelCosts(model: string, usage: Usage): ModelCosts { const shortName = getCanonicalName(model) - // Check if this is an Opus 4.6 model with fast mode active. + // Check if this is a fast-mode-capable Opus model (4.8/4.7/4.6) with fast mode + // active. These share the elevated fast-mode pricing the picker advertises, so + // the tracked cost must match the displayed price for the current default + // (4.8). Non-fast usage stays COST_TIER_5_25, same as the MODEL_COSTS entry. if ( + shortName === firstPartyNameToCanonical(CLAUDE_OPUS_4_8_CONFIG.firstParty) || + shortName === firstPartyNameToCanonical(CLAUDE_OPUS_4_7_CONFIG.firstParty) || shortName === firstPartyNameToCanonical(CLAUDE_OPUS_4_6_CONFIG.firstParty) ) { const isFastMode = usage.speed === 'fast' diff --git a/src/utils/swarm/teammateModel.test.ts b/src/utils/swarm/teammateModel.test.ts index 51ed25c51..759e54275 100644 --- a/src/utils/swarm/teammateModel.test.ts +++ b/src/utils/swarm/teammateModel.test.ts @@ -30,3 +30,21 @@ test('getHardcodedTeammateModelFallback returns a Mistral fallback in mistral mo expect(getHardcodedTeammateModelFallback()).toBe('devstral-latest') }) + +test('getHardcodedTeammateModelFallback returns the current default Opus (4.8) for first party', async () => { + // Regression for #1769: the fallback hardcoded Opus 4.6 while the default Opus + // is now 4.8, so new teammates spawned on an older model. + const { getHardcodedTeammateModelFallback } = + await importFreshTeammateModelModule('firstParty') + + expect(getHardcodedTeammateModelFallback()).toBe('claude-opus-4-8') +}) + +test('getHardcodedTeammateModelFallback is provider-aware (Bedrock gets the Opus 4.8 Bedrock id)', async () => { + const { getHardcodedTeammateModelFallback } = + await importFreshTeammateModelModule('bedrock') + + expect(getHardcodedTeammateModelFallback()).toBe( + 'us.anthropic.claude-opus-4-8-v1', + ) +}) diff --git a/src/utils/swarm/teammateModel.ts b/src/utils/swarm/teammateModel.ts index 0a49009b2..54a016400 100644 --- a/src/utils/swarm/teammateModel.ts +++ b/src/utils/swarm/teammateModel.ts @@ -1,10 +1,10 @@ -import { CLAUDE_OPUS_4_6_CONFIG } from '../model/configs.js' +import { CLAUDE_OPUS_4_8_CONFIG } from '../model/configs.js' import { getAPIProvider } from '../model/providers.js' // @[MODEL LAUNCH]: Update the fallback model below. // When the user has never set teammateDefaultModel in /config, new teammates -// use Opus 4.6. Must be provider-aware so Bedrock/Vertex/Foundry customers get +// use Opus 4.8. Must be provider-aware so Bedrock/Vertex/Foundry customers get // the correct model ID. export function getHardcodedTeammateModelFallback(): string { - return CLAUDE_OPUS_4_6_CONFIG[getAPIProvider()] + return CLAUDE_OPUS_4_8_CONFIG[getAPIProvider()] } diff --git a/src/utils/thinking.test.ts b/src/utils/thinking.test.ts index 1083f8d04..0e385e8c0 100644 --- a/src/utils/thinking.test.ts +++ b/src/utils/thinking.test.ts @@ -117,6 +117,21 @@ describe('modelSupportsThinking — Z.AI GLM', () => { }) }) +describe('modelSupportsAdaptiveThinking — Claude 4 allowlist', () => { + // Provider is mocked to 'openai', so unknown Claude models default to false. + // That makes the allowlist the only reason opus-4-8 returns true here, so + // this test fails if opus-4-8 is dropped from the allowlist (#1769). + test('includes Opus 4.8 in the adaptive-thinking allowlist', async () => { + const { modelSupportsAdaptiveThinking } = await importFreshThinkingModule() + + expect(modelSupportsAdaptiveThinking('claude-opus-4-8')).toBe(true) + // 4.7 stays supported (guards against an accidental allowlist rewrite). + expect(modelSupportsAdaptiveThinking('claude-opus-4-7')).toBe(true) + // A non-allowlisted Claude 4 opus is still excluded on non-1P providers. + expect(modelSupportsAdaptiveThinking('claude-opus-4-2')).toBe(false) + }) +}) + describe('shouldUseThinkingForModel — Ollama', () => { test('does not use thinking for Ollama models when app-level thinking is enabled', async () => { process.env.CLAUDE_CODE_USE_OPENAI = '1' diff --git a/src/utils/thinking.ts b/src/utils/thinking.ts index 55a2bbf2c..86320faef 100644 --- a/src/utils/thinking.ts +++ b/src/utils/thinking.ts @@ -161,7 +161,7 @@ export function modelSupportsAdaptiveThinking(model: string): boolean { } const canonical = getCanonicalName(model) // Supported by a subset of Claude 4 models - if (canonical.includes('opus-4-7') || canonical.includes('opus-4-6') || canonical.includes('sonnet-4-6')) { + if (canonical.includes('opus-4-8') || canonical.includes('opus-4-7') || canonical.includes('opus-4-6') || canonical.includes('sonnet-4-6')) { return true } // Exclude any other known legacy models (allowlist above catches 4-6 variants first)