hqone 6fbc5ba582
fix: preserve custom provider prefixes in think mode model switching (#451)
When using custom providers with model ID prefixes (e.g., vertex_ai/claude-sonnet-4-5),
the think mode switcher was stripping the prefix when mapping to high variants,
causing routing failures in custom LLM proxies.

Changes:
- Add extractModelPrefix() to parse and preserve prefixes like vertex_ai/, openai/, etc.
- Update getHighVariant() to preserve prefix when mapping to -high variants
- Update isAlreadyHighVariant() to check base model name (without prefix)
- Update getThinkingConfig() to check capability using base model name
- Add comprehensive tests for custom provider prefix scenarios

This fix ensures backward compatibility while supporting custom providers
that use prefixed model IDs for routing.

Fixes issue where think mode would break custom providers with prefixed models
by stripping the routing prefix during model variant switching.
2026-01-03 23:21:44 +09:00

223 lines
6.9 KiB
TypeScript

/**
* Think Mode Switcher
*
* This module handles "thinking mode" activation for reasoning-capable models.
* When a user includes "think" keywords in their prompt, models are upgraded to
* their high-reasoning variants with extended thinking budgets.
*
* PROVIDER ALIASING:
* GitHub Copilot acts as a proxy provider that routes to underlying providers
* (Anthropic, Google, OpenAI). We resolve the proxy to the actual provider
* based on model name patterns, allowing GitHub Copilot to inherit thinking
* configurations without duplication.
*
* NORMALIZATION:
* Model IDs are normalized (dots → hyphens in version numbers) to handle API
* inconsistencies defensively while maintaining backwards compatibility.
*/
/**
* Extracts provider-specific prefix from model ID (if present).
* Custom providers may use prefixes for routing (e.g., vertex_ai/, openai/).
*
* @example
* extractModelPrefix("vertex_ai/claude-sonnet-4-5") // { prefix: "vertex_ai/", base: "claude-sonnet-4-5" }
* extractModelPrefix("claude-sonnet-4-5") // { prefix: "", base: "claude-sonnet-4-5" }
* extractModelPrefix("openai/gpt-5.2") // { prefix: "openai/", base: "gpt-5.2" }
*/
function extractModelPrefix(modelID: string): { prefix: string; base: string } {
const slashIndex = modelID.indexOf("/")
if (slashIndex === -1) {
return { prefix: "", base: modelID }
}
return {
prefix: modelID.slice(0, slashIndex + 1),
base: modelID.slice(slashIndex + 1),
}
}
/**
* Normalizes model IDs to use consistent hyphen formatting.
* GitHub Copilot may use dots (claude-opus-4.5) but our maps use hyphens (claude-opus-4-5).
* This ensures lookups work regardless of format.
*
* @example
* normalizeModelID("claude-opus-4.5") // "claude-opus-4-5"
* normalizeModelID("gemini-3.5-pro") // "gemini-3-5-pro"
* normalizeModelID("gpt-5.2") // "gpt-5-2"
* normalizeModelID("vertex_ai/claude-opus-4.5") // "vertex_ai/claude-opus-4-5"
*/
function normalizeModelID(modelID: string): string {
// Replace dots with hyphens when followed by a digit
// This handles version numbers like 4.5 → 4-5, 5.2 → 5-2
return modelID.replace(/\.(\d+)/g, "-$1")
}
/**
* Resolves proxy providers (like github-copilot) to their underlying provider.
* This allows GitHub Copilot to inherit thinking configurations from the actual
* model provider (Anthropic, Google, OpenAI).
*
* @example
* resolveProvider("github-copilot", "claude-opus-4-5") // "anthropic"
* resolveProvider("github-copilot", "gemini-3-pro") // "google"
* resolveProvider("github-copilot", "gpt-5.2") // "openai"
* resolveProvider("anthropic", "claude-opus-4-5") // "anthropic" (unchanged)
*/
function resolveProvider(providerID: string, modelID: string): string {
// GitHub Copilot is a proxy - infer actual provider from model name
if (providerID === "github-copilot") {
const modelLower = modelID.toLowerCase()
if (modelLower.includes("claude")) return "anthropic"
if (modelLower.includes("gemini")) return "google"
if (
modelLower.includes("gpt") ||
modelLower.includes("o1") ||
modelLower.includes("o3")
) {
return "openai"
}
}
// Direct providers or unknown - return as-is
return providerID
}
// Maps model IDs to their "high reasoning" variant (internal convention)
// For OpenAI models, this signals that reasoning_effort should be set to "high"
const HIGH_VARIANT_MAP: Record<string, string> = {
// Claude
"claude-sonnet-4-5": "claude-sonnet-4-5-high",
"claude-opus-4-5": "claude-opus-4-5-high",
// Gemini
"gemini-3-pro": "gemini-3-pro-high",
"gemini-3-pro-low": "gemini-3-pro-high",
"gemini-3-pro-preview": "gemini-3-pro-preview-high",
"gemini-3-flash": "gemini-3-flash-high",
"gemini-3-flash-preview": "gemini-3-flash-preview-high",
// GPT-5
"gpt-5": "gpt-5-high",
"gpt-5-mini": "gpt-5-mini-high",
"gpt-5-nano": "gpt-5-nano-high",
"gpt-5-pro": "gpt-5-pro-high",
"gpt-5-chat-latest": "gpt-5-chat-latest-high",
// GPT-5.1
"gpt-5-1": "gpt-5-1-high",
"gpt-5-1-chat-latest": "gpt-5-1-chat-latest-high",
"gpt-5-1-codex": "gpt-5-1-codex-high",
"gpt-5-1-codex-mini": "gpt-5-1-codex-mini-high",
"gpt-5-1-codex-max": "gpt-5-1-codex-max-high",
// GPT-5.2
"gpt-5-2": "gpt-5-2-high",
"gpt-5-2-chat-latest": "gpt-5-2-chat-latest-high",
"gpt-5-2-pro": "gpt-5-2-pro-high",
}
const ALREADY_HIGH: Set<string> = new Set(Object.values(HIGH_VARIANT_MAP))
export const THINKING_CONFIGS = {
anthropic: {
thinking: {
type: "enabled",
budgetTokens: 64000,
},
maxTokens: 128000,
},
"amazon-bedrock": {
reasoningConfig: {
type: "enabled",
budgetTokens: 32000,
},
maxTokens: 64000,
},
google: {
providerOptions: {
google: {
thinkingConfig: {
thinkingLevel: "HIGH",
},
},
},
},
"google-vertex": {
providerOptions: {
"google-vertex": {
thinkingConfig: {
thinkingLevel: "HIGH",
},
},
},
},
openai: {
reasoning_effort: "high",
},
} as const satisfies Record<string, Record<string, unknown>>
const THINKING_CAPABLE_MODELS = {
anthropic: ["claude-sonnet-4", "claude-opus-4", "claude-3"],
"amazon-bedrock": ["claude", "anthropic"],
google: ["gemini-2", "gemini-3"],
"google-vertex": ["gemini-2", "gemini-3"],
openai: ["gpt-5", "o1", "o3"],
} as const satisfies Record<string, readonly string[]>
export function getHighVariant(modelID: string): string | null {
const normalized = normalizeModelID(modelID)
const { prefix, base } = extractModelPrefix(normalized)
// Check if already high variant (with or without prefix)
if (ALREADY_HIGH.has(base) || base.endsWith("-high")) {
return null
}
// Look up high variant for base model
const highBase = HIGH_VARIANT_MAP[base]
if (!highBase) {
return null
}
// Preserve prefix in the high variant
return prefix + highBase
}
export function isAlreadyHighVariant(modelID: string): boolean {
const normalized = normalizeModelID(modelID)
const { base } = extractModelPrefix(normalized)
return ALREADY_HIGH.has(base) || base.endsWith("-high")
}
type ThinkingProvider = keyof typeof THINKING_CONFIGS
function isThinkingProvider(provider: string): provider is ThinkingProvider {
return provider in THINKING_CONFIGS
}
export function getThinkingConfig(
providerID: string,
modelID: string
): Record<string, unknown> | null {
const normalized = normalizeModelID(modelID)
const { base } = extractModelPrefix(normalized)
if (isAlreadyHighVariant(normalized)) {
return null
}
const resolvedProvider = resolveProvider(providerID, modelID)
if (!isThinkingProvider(resolvedProvider)) {
return null
}
const config = THINKING_CONFIGS[resolvedProvider]
const capablePatterns = THINKING_CAPABLE_MODELS[resolvedProvider]
// Check capability using base model name (without prefix)
const baseLower = base.toLowerCase()
const isCapable = capablePatterns.some((pattern) =>
baseLower.includes(pattern.toLowerCase())
)
return isCapable ? config : null
}