ChatGPT Realtime Model Switcher: 4o-mini, o4-mini, o3 and more!

Allowing you to switch models during a single conversation, and highlight responses by color based on the model generating them

// ==UserScript==
// @name              ChatGPT Realtime Model Switcher: 4o-mini, o4-mini, o3 and more!
// @name:zh-CN        ChatGPT 模型切换助手: 4o-mini、o4-mini、o3 等更多...
// @name:zh-TW        ChatGPT 模型切換助手: 4o-mini、o4-mini、o3 等更多...
// @namespace         http://tampermonkey.net/
// @version           0.54.1
// @description       Allowing you to switch models during a single conversation, and highlight responses by color based on the model generating them
// @description:zh-CN 让您在对话中随意切换语言模型,并用不同颜色标示生成回应的语言模型
// @description:zh-TW 讓您在對話中隨意切換語言模型,並用不同顏色標示生成回答的語言模型
// @match             *://chatgpt.com/*
// @author            d0gkiller87
// @license           MIT
// @grant             unsafeWindow
// @grant             GM.getValue
// @grant             GM.setValue
// @grant             GM.deleteValue
// @grant             GM_registerMenuCommand
// @grant             GM.registerMenuCommand
// @grant             GM.unregisterMenuCommand
// @run-at            document-idle
// @icon              https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com
// ==/UserScript==

(async function() {
  'use strict';

  function injectStyle( style, isDisabled = false ) {
    const styleNode = document.createElement( 'style' );
    styleNode.type = 'text/css';
    styleNode.textContent = style;
    document.head.appendChild( styleNode );
    styleNode.disabled = isDisabled;
    return styleNode;
  }

  const PlanType = Object.freeze({
    free: 0,
    plus: 1,
    pro : 2
  });

  class ModelSwitcher {
    getPlanType() {
      for ( const scriptNode of document.querySelectorAll( 'script' ) ) {
        let match;
        while ( ( match = /\\"planType\\"\s*,\s*\\"(\w+?)\\"/.exec( scriptNode.innerHTML ) ) !== null ) {
          return match[1];
        }
      }
      return 'free'
    }

    async init() {
      this.model = await GM.getValue( 'model', 'auto' );
      this.buttons = {};
      this.offsetX = 0;
      this.offsetY = 0;
      this.isDragging = false;
      this.shouldCancelClick = false;
      this.modelSelector = null;
      this.isMenuVisible = await GM.getValue( 'isMenuVisible', true );
      this.isMenuVisibleCommandId = null;
      this.modelHighlightStyleNode = null;
      this.isModelHighlightEnabled = await GM.getValue( 'isModelHighlightEnabled', true );
      this.isModelHighlightEnabledCommandId = null;
      this.isMenuVertical = await GM.getValue( 'isMenuVertical', true );
      this.isMenuVerticalCommandId = null;
      this.conversationUrlRegex = new RegExp( /https:\/\/chatgpt\.com\/backend-api\/.*conversation/ );

      const planType = PlanType[ this.getPlanType() ];

      const models = [
        // [ PlanType.pro, "o1", "o1" ], // retired
        [ PlanType.pro, "o1-pro", "o1-pro" ],
        // [ PlanType.free, "o3-mini", "o3-mini" ], // retired
        [ PlanType.plus, "o3", "o3" ],
        [ PlanType.free, "o4-mini", "o4-mini" ],
        [ PlanType.plus, "o4-mini-high", "o4-mini-high" ],
        [ PlanType.free, "gpt-3.5", "gpt-3-5" ],
        [ PlanType.free, "4o-mini", "gpt-4o-mini" ],
        [ PlanType.free, "4.1-mini", "gpt-4-1-mini" ],
        // [ PlanType.free, "gpt-4", "gpt-4" ], // same as 4o
        [ PlanType.free, "gpt-4o", "gpt-4o" ],
        [ PlanType.plus, "gpt-4.1", "gpt-4-1" ],
        // [ PlanType.plus, "4o-jawbone", "4o-jawbone" ], // retired (https://x.com/testingcatalog/status/1915483050953125965)
        [ PlanType.plus, "gpt-4.5", "gpt-4-5" ],
        [ PlanType.free, "default", "auto" ],
      ];

      this.availableModels = {};
      for ( const [ minimumPlan, modelName, modelValue ] of models ) {
        if ( planType >= minimumPlan ) {
          this.availableModels[modelName] = modelValue;
        }
      }
    }

    hookFetch() {
      const originalFetch = unsafeWindow.fetch;
      unsafeWindow.fetch = async ( resource, config = {} ) => {
        if (
          typeof resource === 'string' &&
          resource.match( this.conversationUrlRegex ) &&
          config.method === 'POST' &&
          config.headers &&
          config.headers['Content-Type'] === 'application/json' &&
          config.body &&
          this.model !== 'auto'
        ) {
          const body = JSON.parse( config.body );
          body.model = this.model;
          config.body = JSON.stringify( body );
        }
        return originalFetch( resource, config );
      };
    }

    injectToggleButtonStyle() {
      let style = `
        :root {
          color-scheme: light dark;
        }
        #model-selector {
          position: absolute;
          display: flex;
          flex-direction: column;
          gap: 6px;
          cursor: grab;
        }
        #model-selector.horizontal {
          flex-direction: row;
        }
        #model-selector.hidden {
          display: none;
        }
        #model-selector button {
          background: none;
          border: 1px solid light-dark(#151515, white);
          color: light-dark(#151515, white);
          padding: 6px;
          cursor: pointer;
          font-size: 0.9rem;
          user-select: none;
        }
        #model-selector button.selected {
          color: light-dark(white, white);
        }
        :root {
          --o1-pro-color: 139, 232, 27;
          --o3-color: 139, 232, 27;
          --gpt-3-5-color: 0, 106, 129;
          --gpt-4-1-color: 13, 121, 255;
          --gpt-4-5-color: 126, 3, 165;
          --gpt-4o-color: 18, 45, 134;
          --o4-mini-high-color: 176, 53, 0;
          --o4-mini-color: 203, 91, 0;
          --gpt-4o-jawbone-color: 201, 42, 42;
          --gpt-4o-mini-color: 67, 162, 90;
          --gpt-4-1-mini-color: 117, 166, 12;
          --auto-color: 131, 131, 139;

          --unknown-model-btn-color: 67, 162, 90;
          --unknown-model-box-shadow-color: 48, 255, 19;
        }
      `;

      for ( const model of Object.values( this.availableModels ) ) {
        style += `
          #model-selector button.btn-${ model } {
            background-color: rgb(var(--${ model }-color, var(--unknown-model-btn-color)));
          }
        `;
      }

      injectStyle( style );
    }

    refreshButtons() {
      for ( const [ model, button ] of Object.entries( this.buttons ) ) {
        const isSelected = model === `btn-${ this.model }`;
        button.classList.toggle( model, isSelected );
        button.classList.toggle( 'selected', isSelected );
      }
    }

    async reloadMenuVisibleToggle() {
      this.isMenuVisibleCommandId = await GM.registerMenuCommand(
        `${ this.isMenuVisible ? '☑︎' : '☐' } Show model selector`,
        async () => {
          this.isMenuVisible = !this.isMenuVisible;
          await GM.setValue( 'isMenuVisible', this.isMenuVisible );
          this.modelSelector.classList.toggle( 'hidden', !this.isMenuVisible );
          this.reloadMenuVisibleToggle();
        },
        this.isMenuVisibleCommandId ? { id: this.isMenuVisibleCommandId } : {}
      );
    }

    async reloadMenuVerticalToggle() {
      this.isMenuVerticalCommandId = await GM.registerMenuCommand(
        `┖ Style: ${ this.isMenuVertical ? 'vertical ↕' : 'horizontal ↔' }`,
        async () => {
          this.isMenuVertical = !this.isMenuVertical;
          await GM.setValue( 'isMenuVertical', this.isMenuVertical );

          const originalRight = parseInt( this.modelSelector.style.left ) + this.modelSelector.offsetWidth;
          const originalBottom = parseInt( this.modelSelector.style.top ) + this.modelSelector.offsetHeight;

          this.modelSelector.style.visibility = 'hidden';
          this.modelSelector.style.left = '0px';
          this.modelSelector.style.top = '0px';

          this.modelSelector.classList.toggle( 'horizontal', !this.isMenuVertical );

          this.modelSelector.style.left = `${ originalRight - this.modelSelector.offsetWidth }px`;
          this.modelSelector.style.top = `${ originalBottom - this.modelSelector.offsetHeight }px`;
          this.modelSelector.style.visibility = 'visible';

          await GM.setValue( 'relativeMenuPosition', this.getCurrentRelativeMenuPosition() );
          this.reloadMenuVerticalToggle();
        },
        this.isMenuVerticalCommandId ? { id: this.isMenuVerticalCommandId } : {}
      );
    }

    injectMessageModelHighlightStyle() {
      let style = `
        div[data-message-model-slug] {
          padding: 0px 5px;
          box-shadow: 0 0 3px 3px rgba(var(--unknown-model-box-shadow-color), 0.65);
        }
      `;
      for ( const model of Object.values( this.availableModels ) ) {
        style += `
        div[data-message-model-slug="${ model }"] {
          box-shadow: 0 0 3px 3px rgba(var(--${ model }-color, var(--unknown-model-box-shadow-color)), 0.8);
        }
        `;
      }
      this.modelHighlightStyleNode = injectStyle( style, !this.isModelHighlightEnabled );
    }

    async reloadMessageModelHighlightToggle() {
      this.isModelHighlightEnabledCommandId = await GM.registerMenuCommand(
        `${ this.isModelHighlightEnabled ? '☑︎' : '☐' } Show model identifer`,
        async () => {
          this.isModelHighlightEnabled = !this.isModelHighlightEnabled;
          await GM.setValue( 'isModelHighlightEnabled', this.isModelHighlightEnabled );
          this.modelHighlightStyleNode.disabled = !this.isModelHighlightEnabled;
          this.reloadMessageModelHighlightToggle();
        },
        this.isModelHighlightEnabledCommandId ? { id: this.isModelHighlightEnabledCommandId } : {}
      );
    }

    createModelSelectorMenu() {
      this.modelSelector = document.createElement( 'div' );
      this.modelSelector.id = 'model-selector';

      for ( const [ modelName, modelValue ] of Object.entries( this.availableModels ) ) {
        const button = document.createElement( 'button' );
        button.textContent = modelName;
        button.title = modelValue;
        button.addEventListener(
          'click',
          async event => {
            if ( this.shouldCancelClick ) {
              event.preventDefault();
              event.stopImmediatePropagation();
              return;
            }
            this.model = modelValue;
            await GM.setValue( 'model', modelValue );
            this.refreshButtons();
          }
        );
        this.modelSelector.appendChild( button );
        this.buttons[`btn-${ modelValue }`] = button;
      }
      this.modelSelector.classList.toggle( 'hidden', !this.isMenuVisible );
      this.modelSelector.classList.toggle( 'horizontal', !this.isMenuVertical );
      return this.modelSelector;
    }

    injectMenu() {
      document.body.appendChild( this.modelSelector );
    }

    monitorBodyChanges() {
      const observer = new MutationObserver( mutationsList => {
        for ( const mutation of mutationsList ) {
          if ( document.body.querySelector( '#model-selector' ) ) continue;
          this.injectMenu();
          break;
        }
      });
      observer.observe( document.body, { childList: true } );
    }

    getDefaultRelativeMenuPosition() {
      return {
        offsetRight: 33,
        offsetBottom: 36
      };
    }

    relativeToAbsolutePosition( relativeMenuPosition ) {
      return {
        left: `${ window.innerWidth - this.modelSelector.offsetWidth - relativeMenuPosition.offsetRight }px`,
        top: `${ window.innerHeight - this.modelSelector.offsetHeight - relativeMenuPosition.offsetBottom }px`
      }
    }

    getCurrentRelativeMenuPosition() {
      return {
        offsetRight: window.innerWidth - parseInt( this.modelSelector.style.left ) - this.modelSelector.offsetWidth,
        offsetBottom: window.innerHeight - parseInt( this.modelSelector.style.top ) - this.modelSelector.offsetHeight
      }
    }

    async restoreMenuPosition() {
      const menuPosition = await GM.getValue( 'menuPosition', null ); // <= v0.53.1 migration
      if ( menuPosition ) {
        this.modelSelector.style.left = menuPosition.left;
        this.modelSelector.style.top = menuPosition.top;
        await GM.setValue(
          'relativeMenuPosition', {
            offsetRight: window.innerWidth - parseInt( menuPosition.left ) - this.modelSelector.offsetWidth,
            offsetBottom: window.innerHeight - parseInt( menuPosition.top ) - this.modelSelector.offsetHeight
          }
        );
        await GM.deleteValue( 'menuPosition' );
      } else {
        const relativeMenuPosition = await GM.getValue( 'relativeMenuPosition', this.getDefaultRelativeMenuPosition() );
        const absoluteMenuPosition = this.relativeToAbsolutePosition( relativeMenuPosition );
        this.modelSelector.style.left = absoluteMenuPosition.left;
        this.modelSelector.style.top = absoluteMenuPosition.top;
      }
    }

    monitorWindowResize() {
      window.addEventListener(
        'resize', async event => {
          const relativeMenuPosition = await GM.getValue( 'relativeMenuPosition', this.getDefaultRelativeMenuPosition() );
          const absoluteMenuPosition = this.relativeToAbsolutePosition( relativeMenuPosition );
          this.modelSelector.style.left = absoluteMenuPosition.left;
          this.modelSelector.style.top = absoluteMenuPosition.top;
        }
      );
    }

    async registerResetMenuPositionCommand() {
      await GM.registerMenuCommand(
        '⟲ Reset menu position',
        async () => {
          const defaultRelativeMenuPosition = this.getDefaultRelativeMenuPosition();
          const defaultAbsoluteMenuPosition = this.relativeToAbsolutePosition( defaultRelativeMenuPosition );
          this.modelSelector.style.left = defaultAbsoluteMenuPosition.left;
          this.modelSelector.style.top = defaultAbsoluteMenuPosition.top;
          await GM.setValue( 'relativeMenuPosition', defaultRelativeMenuPosition );
        }
      );
    }

    getPoint( event ) {
      return event.touches ? event.touches[0] : event;
    }

    mouseDownHandler( event ) {
      const point = this.getPoint( event );
      this.offsetX = point.clientX - this.modelSelector.offsetLeft;
      this.offsetY = point.clientY - this.modelSelector.offsetTop;
      this.isDragging = true;
      this.shouldCancelClick = false;
      this.modelSelector.style.cursor = 'grabbing';
    }

    mouseMoveHandler( event ) {
      if ( !this.isDragging ) return;

      const point = this.getPoint( event );
      const oldLeft = this.modelSelector.style.left;
      const oldTop = this.modelSelector.style.top;
      this.modelSelector.style.left = ( point.clientX - this.offsetX ) + 'px';
      this.modelSelector.style.top = ( point.clientY - this.offsetY ) + 'px';
      if ( !this.shouldCancelClick && ( this.modelSelector.style.left != oldLeft || this.modelSelector.style.top != oldTop ) ) {
        this.shouldCancelClick = true;
      }

      // Prevent scrolling on touch
      if ( event.cancelable ) event.preventDefault();
    }

    async mouseUpHandler( event ) {
      this.isDragging = false;
      this.modelSelector.style.cursor = 'grab';
      document.body.style.userSelect = '';
      await GM.setValue( 'relativeMenuPosition', this.getCurrentRelativeMenuPosition() );
    }

    registerGrabbing() {
      // Mouse
      this.modelSelector.addEventListener( 'mousedown', this.mouseDownHandler.bind( this ) );
      document.addEventListener( 'mousemove', this.mouseMoveHandler.bind( this ) );
      document.addEventListener( 'mouseup', this.mouseUpHandler.bind( this ) );

      // Touch
      this.modelSelector.addEventListener( 'touchstart', this.mouseDownHandler.bind( this ), { passive: false } );
      document.addEventListener( 'touchmove', this.mouseMoveHandler.bind( this ), { passive: false } );
      document.addEventListener( 'touchend', this.mouseUpHandler.bind( this ) );
    }
  }

  const switcher = new ModelSwitcher();
  await switcher.init();

  switcher.hookFetch();

  switcher.injectToggleButtonStyle();
  switcher.injectMessageModelHighlightStyle();

  switcher.createModelSelectorMenu();
  await switcher.registerResetMenuPositionCommand();
  await switcher.reloadMenuVisibleToggle();
  await switcher.reloadMenuVerticalToggle();
  await switcher.reloadMessageModelHighlightToggle();

  switcher.refreshButtons();
  switcher.monitorBodyChanges();
  switcher.injectMenu();

  await switcher.restoreMenuPosition();
  switcher.monitorWindowResize();
  switcher.registerGrabbing();
})();