mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-01-02 14:22:28 +08:00
feat: Display AI reasoning/thinking blocks in chat interface (#152)
* feat: Add reasoning/thinking blocks display in chat interface * feat: add multi-provider options support and replace custom reasoning UI with AI Elements * resolve conflicting reasoning configs and correct provider-specific reasoning parameters * try to solve conflict * fix: simplify reasoning display and remove unnecessary dependencies - Remove Streamdown dependency (~5MB) - reasoning is plain text only - Fix Bedrock providerOptions merging for Claude reasoning configs - Remove unsupported DeepSeek reasoning configuration - Clean up unused environment variables (REASONING_BUDGET_TOKENS, REASONING_EFFORT, DEEPSEEK_REASONING_*) - Remove dead commented code from route.ts Reasoning blocks contain plain thinking text and don't need markdown/diagram/code rendering. * feat: comprehensive reasoning support improvements Major improvements: - Auto-enable reasoning display for all supported models - Fix provider-specific reasoning configurations - Remove unnecessary Streamdown dependency (~5MB) - Clean up debug logging Provider changes: - OpenAI: Auto-enable reasoningSummary for o1/o3/gpt-5 models - Google: Auto-enable includeThoughts for Gemini 2.5/3 models - Bedrock: Restrict reasoningConfig to only Claude/Nova (fixes MiniMax error) - Ollama: Add thinking support for qwen3-like models Other improvements: - Remove ENABLE_REASONING toggle (always enabled) - Fix Bedrock providerOptions merging for Claude - Simplify reasoning component (plain text rendering) - Clean up unused environment variables * fix: critical bugs and documentation gaps in reasoning support Critical fixes: - Fix Bedrock shallow merge bug (deep merge preserves anthropicBeta + reasoningConfig) - Add parseInt validation with parseIntSafe helper (prevents NaN errors) - Validate all numeric env vars with min/max ranges Documentation improvements: - Add BEDROCK_REASONING_BUDGET_TOKENS and BEDROCK_REASONING_EFFORT to env.example - Add OLLAMA_ENABLE_THINKING to env.example - Update JSDoc with accurate env var list and ranges Code cleanup: - Remove debug console.log statements from route.ts - Refactor duplicate providerOptions assignments --------- Co-authored-by: Dayuan Jiang <34411969+DayuanJiang@users.noreply.github.com> Co-authored-by: Dayuan Jiang <jdy.toh@gmail.com>
This commit is contained in:
@@ -56,6 +56,295 @@ const ANTHROPIC_BETA_HEADERS = {
|
||||
"anthropic-beta": "fine-grained-tool-streaming-2025-05-14",
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse integer from environment variable with validation
|
||||
*/
|
||||
function parseIntSafe(
|
||||
value: string | undefined,
|
||||
varName: string,
|
||||
min?: number,
|
||||
max?: number,
|
||||
): number | undefined {
|
||||
if (!value) return undefined
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error(`${varName} must be a valid integer, got: ${value}`)
|
||||
}
|
||||
if (min !== undefined && parsed < min) {
|
||||
throw new Error(`${varName} must be >= ${min}, got: ${parsed}`)
|
||||
}
|
||||
if (max !== undefined && parsed > max) {
|
||||
throw new Error(`${varName} must be <= ${max}, got: ${parsed}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Build provider-specific options from environment variables
|
||||
* Supports various AI SDK providers with their unique configuration options
|
||||
*
|
||||
* Environment variables:
|
||||
* - OPENAI_REASONING_EFFORT: OpenAI reasoning effort level (minimal/low/medium/high) - for o1/o3/gpt-5
|
||||
* - OPENAI_REASONING_SUMMARY: OpenAI reasoning summary (none/brief/detailed) - auto-enabled for o1/o3/gpt-5
|
||||
* - ANTHROPIC_THINKING_BUDGET_TOKENS: Anthropic thinking budget in tokens (1024-64000)
|
||||
* - ANTHROPIC_THINKING_TYPE: Anthropic thinking type (enabled)
|
||||
* - GOOGLE_THINKING_BUDGET: Google Gemini 2.5 thinking budget in tokens (1024-100000)
|
||||
* - GOOGLE_THINKING_LEVEL: Google Gemini 3 thinking level (low/high)
|
||||
* - AZURE_REASONING_EFFORT: Azure/OpenAI reasoning effort (low/medium/high)
|
||||
* - AZURE_REASONING_SUMMARY: Azure reasoning summary (none/brief/detailed)
|
||||
* - BEDROCK_REASONING_BUDGET_TOKENS: Bedrock Claude reasoning budget in tokens (1024-64000)
|
||||
* - BEDROCK_REASONING_EFFORT: Bedrock Nova reasoning effort (low/medium/high)
|
||||
* - OLLAMA_ENABLE_THINKING: Enable Ollama thinking mode (set to "true")
|
||||
*/
|
||||
function buildProviderOptions(
|
||||
provider: ProviderName,
|
||||
modelId?: string,
|
||||
): Record<string, any> | undefined {
|
||||
const options: Record<string, any> = {}
|
||||
|
||||
switch (provider) {
|
||||
case "openai": {
|
||||
const reasoningEffort = process.env.OPENAI_REASONING_EFFORT
|
||||
const reasoningSummary = process.env.OPENAI_REASONING_SUMMARY
|
||||
|
||||
// OpenAI reasoning models (o1, o3, gpt-5) need reasoningSummary to return thoughts
|
||||
if (
|
||||
modelId &&
|
||||
(modelId.includes("o1") ||
|
||||
modelId.includes("o3") ||
|
||||
modelId.includes("gpt-5"))
|
||||
) {
|
||||
options.openai = {
|
||||
// Auto-enable reasoning summary for reasoning models (default: detailed)
|
||||
reasoningSummary:
|
||||
(reasoningSummary as "none" | "brief" | "detailed") ||
|
||||
"detailed",
|
||||
}
|
||||
|
||||
// Optionally configure reasoning effort
|
||||
if (reasoningEffort) {
|
||||
options.openai.reasoningEffort = reasoningEffort as
|
||||
| "minimal"
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
}
|
||||
} else if (reasoningEffort || reasoningSummary) {
|
||||
// Non-reasoning models: only apply if explicitly configured
|
||||
options.openai = {}
|
||||
if (reasoningEffort) {
|
||||
options.openai.reasoningEffort = reasoningEffort as
|
||||
| "minimal"
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
}
|
||||
if (reasoningSummary) {
|
||||
options.openai.reasoningSummary = reasoningSummary as
|
||||
| "none"
|
||||
| "brief"
|
||||
| "detailed"
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "anthropic": {
|
||||
const thinkingBudget = parseIntSafe(
|
||||
process.env.ANTHROPIC_THINKING_BUDGET_TOKENS,
|
||||
"ANTHROPIC_THINKING_BUDGET_TOKENS",
|
||||
1024,
|
||||
64000,
|
||||
)
|
||||
const thinkingType =
|
||||
process.env.ANTHROPIC_THINKING_TYPE || "enabled"
|
||||
|
||||
if (thinkingBudget) {
|
||||
options.anthropic = {
|
||||
thinking: {
|
||||
type: thinkingType,
|
||||
budgetTokens: thinkingBudget,
|
||||
},
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "google": {
|
||||
const reasoningEffort = process.env.GOOGLE_REASONING_EFFORT
|
||||
const thinkingBudgetVal = parseIntSafe(
|
||||
process.env.GOOGLE_THINKING_BUDGET,
|
||||
"GOOGLE_THINKING_BUDGET",
|
||||
1024,
|
||||
100000,
|
||||
)
|
||||
const thinkingLevel = process.env.GOOGLE_THINKING_LEVEL
|
||||
|
||||
// Google Gemini 2.5/3 models think by default, but need includeThoughts: true
|
||||
// to return the reasoning in the response
|
||||
if (
|
||||
modelId &&
|
||||
(modelId.includes("gemini-2") ||
|
||||
modelId.includes("gemini-3") ||
|
||||
modelId.includes("gemini2") ||
|
||||
modelId.includes("gemini3"))
|
||||
) {
|
||||
const thinkingConfig: Record<string, any> = {
|
||||
includeThoughts: true,
|
||||
}
|
||||
|
||||
// Optionally configure thinking budget or level
|
||||
if (
|
||||
thinkingBudgetVal &&
|
||||
(modelId.includes("2.5") || modelId.includes("2-5"))
|
||||
) {
|
||||
thinkingConfig.thinkingBudget = thinkingBudgetVal
|
||||
} else if (
|
||||
thinkingLevel &&
|
||||
(modelId.includes("gemini-3") ||
|
||||
modelId.includes("gemini3"))
|
||||
) {
|
||||
thinkingConfig.thinkingLevel = thinkingLevel as
|
||||
| "low"
|
||||
| "high"
|
||||
}
|
||||
|
||||
options.google = { thinkingConfig }
|
||||
} else if (reasoningEffort) {
|
||||
options.google = {
|
||||
reasoningEffort: reasoningEffort as
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high",
|
||||
}
|
||||
}
|
||||
|
||||
// Keep existing Google options
|
||||
const options_obj: Record<string, any> = {}
|
||||
const candidateCount = parseIntSafe(
|
||||
process.env.GOOGLE_CANDIDATE_COUNT,
|
||||
"GOOGLE_CANDIDATE_COUNT",
|
||||
1,
|
||||
8,
|
||||
)
|
||||
if (candidateCount) {
|
||||
options_obj.candidateCount = candidateCount
|
||||
}
|
||||
const topK = parseIntSafe(
|
||||
process.env.GOOGLE_TOP_K,
|
||||
"GOOGLE_TOP_K",
|
||||
1,
|
||||
100,
|
||||
)
|
||||
if (topK) {
|
||||
options_obj.topK = topK
|
||||
}
|
||||
if (process.env.GOOGLE_TOP_P) {
|
||||
const topP = Number.parseFloat(process.env.GOOGLE_TOP_P)
|
||||
if (Number.isNaN(topP) || topP < 0 || topP > 1) {
|
||||
throw new Error(
|
||||
`GOOGLE_TOP_P must be a number between 0 and 1, got: ${process.env.GOOGLE_TOP_P}`,
|
||||
)
|
||||
}
|
||||
options_obj.topP = topP
|
||||
}
|
||||
|
||||
if (Object.keys(options_obj).length > 0) {
|
||||
options.google = { ...options.google, ...options_obj }
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "azure": {
|
||||
const reasoningEffort = process.env.AZURE_REASONING_EFFORT
|
||||
const reasoningSummary = process.env.AZURE_REASONING_SUMMARY
|
||||
|
||||
if (reasoningEffort || reasoningSummary) {
|
||||
options.azure = {}
|
||||
if (reasoningEffort) {
|
||||
options.azure.reasoningEffort = reasoningEffort as
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
}
|
||||
if (reasoningSummary) {
|
||||
options.azure.reasoningSummary = reasoningSummary as
|
||||
| "none"
|
||||
| "brief"
|
||||
| "detailed"
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "bedrock": {
|
||||
const budgetTokens = parseIntSafe(
|
||||
process.env.BEDROCK_REASONING_BUDGET_TOKENS,
|
||||
"BEDROCK_REASONING_BUDGET_TOKENS",
|
||||
1024,
|
||||
64000,
|
||||
)
|
||||
const reasoningEffort = process.env.BEDROCK_REASONING_EFFORT
|
||||
|
||||
// Bedrock reasoning ONLY for Claude and Nova models
|
||||
// Other models (MiniMax, etc.) don't support reasoningConfig
|
||||
if (
|
||||
modelId &&
|
||||
(budgetTokens || reasoningEffort) &&
|
||||
(modelId.includes("claude") ||
|
||||
modelId.includes("anthropic") ||
|
||||
modelId.includes("nova") ||
|
||||
modelId.includes("amazon"))
|
||||
) {
|
||||
const reasoningConfig: Record<string, any> = { type: "enabled" }
|
||||
|
||||
// Claude models: use budgetTokens (1024-64000)
|
||||
if (
|
||||
budgetTokens &&
|
||||
(modelId.includes("claude") ||
|
||||
modelId.includes("anthropic"))
|
||||
) {
|
||||
reasoningConfig.budgetTokens = budgetTokens
|
||||
}
|
||||
// Nova models: use maxReasoningEffort (low/medium/high)
|
||||
else if (
|
||||
reasoningEffort &&
|
||||
(modelId.includes("nova") || modelId.includes("amazon"))
|
||||
) {
|
||||
reasoningConfig.maxReasoningEffort = reasoningEffort as
|
||||
| "low"
|
||||
| "medium"
|
||||
| "high"
|
||||
}
|
||||
|
||||
options.bedrock = { reasoningConfig }
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "ollama": {
|
||||
const enableThinking = process.env.OLLAMA_ENABLE_THINKING
|
||||
// Ollama supports reasoning with think: true for models like qwen3
|
||||
if (enableThinking === "true") {
|
||||
options.ollama = { think: true }
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "deepseek":
|
||||
case "openrouter":
|
||||
case "siliconflow": {
|
||||
// These providers don't have reasoning configs in AI SDK yet
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return Object.keys(options).length > 0 ? options : undefined
|
||||
}
|
||||
|
||||
// Map of provider to required environment variable
|
||||
const PROVIDER_ENV_VARS: Record<ProviderName, string | null> = {
|
||||
bedrock: null, // AWS SDK auto-uses IAM role on AWS, or env vars locally
|
||||
@@ -205,6 +494,9 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
let providerOptions: any
|
||||
let headers: Record<string, string> | undefined
|
||||
|
||||
// Build provider-specific options from environment variables
|
||||
const customProviderOptions = buildProviderOptions(provider, modelId)
|
||||
|
||||
switch (provider) {
|
||||
case "bedrock": {
|
||||
// Use credential provider chain for IAM role support (Lambda, EC2, etc.)
|
||||
@@ -216,7 +508,15 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
model = bedrockProvider(modelId)
|
||||
// Add Anthropic beta options if using Claude models via Bedrock
|
||||
if (modelId.includes("anthropic.claude")) {
|
||||
providerOptions = BEDROCK_ANTHROPIC_BETA
|
||||
// Deep merge to preserve both anthropicBeta and reasoningConfig
|
||||
providerOptions = {
|
||||
bedrock: {
|
||||
...BEDROCK_ANTHROPIC_BETA.bedrock,
|
||||
...(customProviderOptions?.bedrock || {}),
|
||||
},
|
||||
}
|
||||
} else if (customProviderOptions) {
|
||||
providerOptions = customProviderOptions
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -342,6 +642,11 @@ export function getAIModel(overrides?: ClientOverrides): ModelConfig {
|
||||
)
|
||||
}
|
||||
|
||||
// Apply provider-specific options for all providers except bedrock (which has special handling)
|
||||
if (customProviderOptions && provider !== "bedrock" && !providerOptions) {
|
||||
providerOptions = customProviderOptions
|
||||
}
|
||||
|
||||
return { model, providerOptions, headers, modelId }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user