From 589cf60252b3f3074e76a0746f9eb73837d15848 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 9 Dec 2025 15:48:54 +0900 Subject: [PATCH] feat(hooks): add think-mode hook for automatic model switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects thinking keywords (ultrathink, deepthink, etc.) and switches to thinking-capable models automatically. Supports model patterns: - claude-sonnet-4-0 -> claude-sonnet-4-0-max-thinking - claude-sonnet-4-20250514 -> claude-sonnet-4-20250514-max-thinking 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) --- src/hooks/think-mode/detector.ts | 57 ++++++++++++++++++++ src/hooks/think-mode/index.ts | 73 +++++++++++++++++++++++++ src/hooks/think-mode/switcher.ts | 91 ++++++++++++++++++++++++++++++++ src/hooks/think-mode/types.ts | 20 +++++++ 4 files changed, 241 insertions(+) create mode 100644 src/hooks/think-mode/detector.ts create mode 100644 src/hooks/think-mode/index.ts create mode 100644 src/hooks/think-mode/switcher.ts create mode 100644 src/hooks/think-mode/types.ts diff --git a/src/hooks/think-mode/detector.ts b/src/hooks/think-mode/detector.ts new file mode 100644 index 0000000..f67cfc7 --- /dev/null +++ b/src/hooks/think-mode/detector.ts @@ -0,0 +1,57 @@ +const ENGLISH_PATTERNS = [/\bultrathink\b/i, /\bthink\b/i] + +const MULTILINGUAL_KEYWORDS = [ + "생각", "고민", "검토", "제대로", + "思考", "考虑", "考慮", + "思考", "考え", "熟考", + "सोच", "विचार", + "تفكير", "تأمل", + "চিন্তা", "ভাবনা", + "думать", "думай", "размышлять", "размышляй", + "pensar", "pense", "refletir", "reflita", + "pensar", "piensa", "reflexionar", "reflexiona", + "penser", "pense", "réfléchir", "réfléchis", + "denken", "denk", "nachdenken", + "suy nghĩ", "cân nhắc", + "düşün", "düşünmek", + "pensare", "pensa", "riflettere", "rifletti", + "คิด", "พิจารณา", + "myśl", "myśleć", "zastanów", + "denken", "denk", "nadenken", + "berpikir", "pikir", "pertimbangkan", + "думати", "думай", "роздумувати", + "σκέψου", "σκέφτομαι", + "myslet", "mysli", "přemýšlet", + "gândește", "gândi", "reflectă", + "tänka", "tänk", "fundera", + "gondolkodj", "gondolkodni", + "ajattele", "ajatella", "pohdi", + "tænk", "tænke", "overvej", + "tenk", "tenke", "gruble", + "חשוב", "לחשוב", "להרהר", + "fikir", "berfikir", +] + +const MULTILINGUAL_PATTERNS = MULTILINGUAL_KEYWORDS.map((kw) => new RegExp(kw, "i")) +const THINK_PATTERNS = [...ENGLISH_PATTERNS, ...MULTILINGUAL_PATTERNS] + +const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g +const INLINE_CODE_PATTERN = /`[^`]+`/g + +function removeCodeBlocks(text: string): string { + return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, "") +} + +export function detectThinkKeyword(text: string): boolean { + const textWithoutCode = removeCodeBlocks(text) + return THINK_PATTERNS.some((pattern) => pattern.test(textWithoutCode)) +} + +export function extractPromptText( + parts: Array<{ type: string; text?: string }> +): string { + return parts + .filter((p) => p.type === "text") + .map((p) => p.text || "") + .join("") +} diff --git a/src/hooks/think-mode/index.ts b/src/hooks/think-mode/index.ts new file mode 100644 index 0000000..078e530 --- /dev/null +++ b/src/hooks/think-mode/index.ts @@ -0,0 +1,73 @@ +import { detectThinkKeyword, extractPromptText } from "./detector" +import { getHighVariant, isAlreadyHighVariant } from "./switcher" +import type { ThinkModeState, ThinkModeInput } from "./types" + +export * from "./detector" +export * from "./switcher" +export * from "./types" + +const thinkModeState = new Map() + +export function clearThinkModeState(sessionID: string): void { + thinkModeState.delete(sessionID) +} + +export function createThinkModeHook() { + return { + "chat.params": async ( + output: ThinkModeInput, + sessionID: string + ): Promise => { + const promptText = extractPromptText(output.parts) + + const state: ThinkModeState = { + requested: false, + modelSwitched: false, + } + + if (!detectThinkKeyword(promptText)) { + thinkModeState.set(sessionID, state) + return + } + + state.requested = true + + const currentModel = output.message.model + if (!currentModel) { + thinkModeState.set(sessionID, state) + return + } + + state.providerID = currentModel.providerID + state.modelID = currentModel.modelID + + if (isAlreadyHighVariant(currentModel.modelID)) { + thinkModeState.set(sessionID, state) + return + } + + const highVariant = getHighVariant(currentModel.modelID) + + if (!highVariant) { + thinkModeState.set(sessionID, state) + return + } + + output.message.model = { + providerID: currentModel.providerID, + modelID: highVariant, + } + state.modelSwitched = true + thinkModeState.set(sessionID, state) + }, + + event: async ({ event }: { event: { type: string; properties?: unknown } }) => { + if (event.type === "session.deleted") { + const props = event.properties as { info?: { id?: string } } | undefined + if (props?.info?.id) { + thinkModeState.delete(props.info.id) + } + } + }, + } +} diff --git a/src/hooks/think-mode/switcher.ts b/src/hooks/think-mode/switcher.ts new file mode 100644 index 0000000..c051232 --- /dev/null +++ b/src/hooks/think-mode/switcher.ts @@ -0,0 +1,91 @@ +const HIGH_VARIANT_MAP: Record = { + "claude-sonnet-4-5": "claude-sonnet-4-5-high", + "claude-opus-4-5": "claude-opus-4-5-high", + "gpt-5.1": "gpt-5.1-high", + "gpt-5.1-medium": "gpt-5.1-high", + "gpt-5.1-codex": "gpt-5.1-codex-high", + "gemini-3-pro": "gemini-3-pro-high", + "gemini-3-pro-low": "gemini-3-pro-high", +} + +const ALREADY_HIGH: Set = new Set([ + "claude-sonnet-4-5-high", + "claude-opus-4-5-high", + "gpt-5.1-high", + "gpt-5.1-codex-high", + "gemini-3-pro-high", +]) + +export const THINKING_CONFIGS: Record> = { + anthropic: { + thinking: { + type: "enabled", + budgetTokens: 64000, + }, + }, + "amazon-bedrock": { + reasoningConfig: { + type: "enabled", + budgetTokens: 32000, + }, + }, + google: { + providerOptions: { + google: { + thinkingConfig: { + thinkingLevel: "HIGH", + }, + }, + }, + }, + "google-vertex": { + providerOptions: { + "google-vertex": { + thinkingConfig: { + thinkingLevel: "HIGH", + }, + }, + }, + }, +} + +const THINKING_CAPABLE_MODELS: Record = { + anthropic: ["claude-sonnet-4", "claude-opus-4", "claude-3"], + "amazon-bedrock": ["claude", "anthropic"], + google: ["gemini-2", "gemini-3"], + "google-vertex": ["gemini-2", "gemini-3"], +} + +export function getHighVariant(modelID: string): string | null { + if (ALREADY_HIGH.has(modelID)) { + return null + } + return HIGH_VARIANT_MAP[modelID] ?? null +} + +export function isAlreadyHighVariant(modelID: string): boolean { + return ALREADY_HIGH.has(modelID) || modelID.endsWith("-high") +} + +export function getThinkingConfig( + providerID: string, + modelID: string +): Record | null { + if (isAlreadyHighVariant(modelID)) { + return null + } + + const config = THINKING_CONFIGS[providerID] + const capablePatterns = THINKING_CAPABLE_MODELS[providerID] + + if (!config || !capablePatterns) { + return null + } + + const modelLower = modelID.toLowerCase() + const isCapable = capablePatterns.some((pattern) => + modelLower.includes(pattern.toLowerCase()) + ) + + return isCapable ? config : null +} diff --git a/src/hooks/think-mode/types.ts b/src/hooks/think-mode/types.ts new file mode 100644 index 0000000..c36d523 --- /dev/null +++ b/src/hooks/think-mode/types.ts @@ -0,0 +1,20 @@ +export interface ThinkModeState { + requested: boolean + modelSwitched: boolean + providerID?: string + modelID?: string +} + +export interface ModelRef { + providerID: string + modelID: string +} + +export interface MessageWithModel { + model?: ModelRef +} + +export interface ThinkModeInput { + parts: Array<{ type: string; text?: string }> + message: MessageWithModel +}