feat(hooks): add think-mode hook for automatic model switching

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)
This commit is contained in:
YeonGyu-Kim
2025-12-09 15:48:54 +09:00
parent e5cdaa5192
commit 589cf60252
4 changed files with 241 additions and 0 deletions

View File

@@ -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("")
}

View File

@@ -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<string, ThinkModeState>()
export function clearThinkModeState(sessionID: string): void {
thinkModeState.delete(sessionID)
}
export function createThinkModeHook() {
return {
"chat.params": async (
output: ThinkModeInput,
sessionID: string
): Promise<void> => {
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)
}
}
},
}
}

View File

@@ -0,0 +1,91 @@
const HIGH_VARIANT_MAP: Record<string, string> = {
"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<string> = 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<string, Record<string, unknown>> = {
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<string, string[]> = {
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<string, unknown> | 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
}

View File

@@ -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
}