├── .gitignore ├── statusline.sh ├── statusline.ps1 ├── LICENSE ├── zai.js └── zai-debug.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | *.log 3 | logs/ 4 | *.log.* 5 | 6 | # OS Files 7 | .DS_Store 8 | Thumbs.db 9 | desktop.ini 10 | 11 | # IDE 12 | .vscode/ 13 | .idea/ 14 | *.swp 15 | *.swo 16 | *~ 17 | 18 | # Node modules 19 | node_modules/ 20 | 21 | # Personal config files 22 | config.json 23 | settings.json 24 | 25 | # API Keys 26 | .env 27 | .env.local 28 | *.key 29 | 30 | # Temporary files 31 | tmp/ 32 | temp/ 33 | *.tmp 34 | -------------------------------------------------------------------------------- /statusline.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # StatusLine for Claude Code CLI 4 | # Author: Bedolla 5 | # Date: 2025-10-25 6 | # Version: 1.0 7 | # Requirements: bash 4.0+ or zsh 5.0+, Git installed 8 | # Compatibility: macOS, Linux 9 | # 10 | # Description: 11 | # Displays useful information in 2 lines: 12 | # - Line 1: Path | Branch + Git Status | Model 13 | # - Line 2: Cost USD | Session Duration | Lines +/- (Net: N) 14 | 15 | # ============================================================================ 16 | # CONFIGURATION 17 | # ============================================================================ 18 | 19 | SHOW_DETAILED_GIT=true # Show detailed git indicators (staged, modified, etc.) 20 | SHOW_API_DURATION=false # Show API call duration in addition to total session duration 21 | MAX_PATH_LENGTH=50 # Maximum characters for path before truncation 22 | 23 | # ============================================================================ 24 | # FUNCTIONS: GitStatus 25 | # ============================================================================ 26 | 27 | # Function: get_git_status 28 | # Description: Extracts Git repository status including branch name, file counts, 29 | # and commit synchronization state with remote. 30 | # Parameters: 31 | # $1 - workspace_dir: Directory path to analyze 32 | # Returns: 33 | # Pipe-separated string: branch|staged|modified|untracked|deleted|conflict|ahead|behind|clean 34 | # Example: "main|2|1|0|0|0|1|0|false" 35 | get_git_status() { 36 | local workspace_dir="$1" 37 | 38 | # Initialize counters and status variables 39 | local branch="" 40 | local staged_files=0 # Files in staging area (git add) 41 | local modified_files=0 # Modified but not staged 42 | local untracked_files=0 # New files not tracked by git 43 | local deleted_files=0 # Deleted files 44 | local conflict_files=0 # Files with merge conflicts 45 | local commits_ahead=0 # Commits ahead of remote 46 | local commits_behind=0 # Commits behind remote 47 | local is_clean=true # True if no pending changes 48 | 49 | # Save current directory to restore later 50 | local original_dir=$(pwd) 51 | 52 | # Change to workspace directory if it exists 53 | if [[ -n "$workspace_dir" && -d "$workspace_dir" ]]; then 54 | cd "$workspace_dir" 2>/dev/null || return 55 | fi 56 | 57 | # Execute git status with machine-readable format 58 | # --porcelain: Stable format for scripts 59 | # --branch: Include branch info in output 60 | local lines 61 | lines=$(git status --porcelain --branch 2>/dev/null) 62 | 63 | # Return to original directory 64 | cd "$original_dir" 65 | 66 | # If no output, not a git repository 67 | if [[ -z "$lines" ]]; then 68 | echo "No Git||||0|0|0|0|0|0|0|false" 69 | return 70 | fi 71 | 72 | # Extract branch information (line starting with ##) 73 | local branch_line=$(echo "$lines" | grep "^##" | head -n 1) 74 | 75 | if [[ -n "$branch_line" ]]; then 76 | # Extract branch name from line like: "## main...origin/main [ahead 1]" 77 | branch=$(echo "$branch_line" | sed -E 's/^## ([^.]+).*/\1/' | sed 's/^## //') 78 | 79 | # Extract commits ahead of remote (if any) 80 | # Uses BASH_REMATCH for bash, falls back to match for zsh 81 | if [[ "$branch_line" =~ ahead\ ([0-9]+) ]]; then 82 | commits_ahead=${BASH_REMATCH[1]:-${match[1]}} 83 | fi 84 | 85 | # Extract commits behind remote (if any) 86 | if [[ "$branch_line" =~ behind\ ([0-9]+) ]]; then 87 | commits_behind=${BASH_REMATCH[1]:-${match[1]}} 88 | fi 89 | fi 90 | 91 | # Process file status lines (exclude branch line) 92 | local file_lines=$(echo "$lines" | grep -v "^##") 93 | 94 | # Parse each file status line 95 | # Format: XY filename (X=staging, Y=working tree) 96 | while IFS= read -r line; do 97 | [[ -z "$line" ]] && continue 98 | [[ ${#line} -lt 2 ]] && continue 99 | 100 | # Extract status codes (first two characters) 101 | local staging_code="${line:0:1}" # Staging area status 102 | local working_code="${line:1:1}" # Working tree status 103 | 104 | # Process staging area status (first column) 105 | # M=Modified, A=Added, R=Renamed, C=Copied, D=Deleted, U=Unmerged, ?=Untracked 106 | case "$staging_code" in 107 | M|A|R|C) 108 | ((staged_files++)) # Ready for commit 109 | ;; 110 | D) 111 | ((staged_files++)) # Deleted and staged 112 | ((deleted_files++)) 113 | ;; 114 | U) 115 | ((conflict_files++)) # Merge conflict 116 | ;; 117 | \?) 118 | if [[ "$working_code" == "?" ]]; then 119 | ((untracked_files++)) # New file not tracked 120 | fi 121 | ;; 122 | esac 123 | 124 | # Process working tree status (second column) 125 | case "$working_code" in 126 | M) 127 | ((modified_files++)) # Modified but not staged 128 | ;; 129 | D) 130 | ((deleted_files++)) # Deleted but not staged 131 | ;; 132 | U) 133 | ((conflict_files++)) # Merge conflict 134 | ;; 135 | esac 136 | done <<< "$file_lines" 137 | 138 | # Determine if repository is clean (no pending changes) 139 | if [[ $staged_files -eq 0 && $modified_files -eq 0 && \ 140 | $untracked_files -eq 0 && $deleted_files -eq 0 && \ 141 | $conflict_files -eq 0 && $commits_ahead -eq 0 && \ 142 | $commits_behind -eq 0 ]]; then 143 | is_clean=true 144 | else 145 | is_clean=false 146 | fi 147 | 148 | # Return pipe-separated values 149 | echo "$branch|$staged_files|$modified_files|$untracked_files|$deleted_files|$conflict_files|$commits_ahead|$commits_behind|$is_clean" 150 | } 151 | 152 | # Function: build_git_indicators 153 | # Description: Builds a compact string with Git status indicators using emojis. 154 | # Parameters: 155 | # $1 - branch: Branch name 156 | # $2 - staged: Number of staged files 157 | # $3 - modified: Number of modified files 158 | # $4 - untracked: Number of untracked files 159 | # $5 - deleted: Number of deleted files 160 | # $6 - conflict: Number of files with conflicts 161 | # $7 - ahead: Commits ahead of remote 162 | # $8 - behind: Commits behind remote 163 | # $9 - clean: Boolean indicating if repository is clean 164 | # Returns: 165 | # String like "main ✅2 ✏️1 ⬆️1" or "main ✓" if clean 166 | build_git_indicators() { 167 | local branch="$1" 168 | local staged="$2" 169 | local modified="$3" 170 | local untracked="$4" 171 | local deleted="$5" 172 | local conflict="$6" 173 | local ahead="$7" 174 | local behind="$8" 175 | local clean="$9" 176 | 177 | local indicators="" 178 | 179 | # Build indicator string with emoji + count for each status 180 | [[ $staged -gt 0 ]] && indicators+="✅$staged " # ✅ Staged files 181 | [[ $modified -gt 0 ]] && indicators+="✏️ $modified " # ✏️ Modified files 182 | [[ $untracked -gt 0 ]] && indicators+="📄$untracked " # 📄 Untracked files 183 | [[ $deleted -gt 0 ]] && indicators+="🗑️ $deleted " # 🗑️ Deleted files 184 | [[ $conflict -gt 0 ]] && indicators+="⚠️ $conflict " # ⚠️ Conflicts 185 | [[ $ahead -gt 0 ]] && indicators+="⬆️ $ahead " # ⬆️ Commits ahead 186 | [[ $behind -gt 0 ]] && indicators+="⬇️ $behind " # ⬇️ Commits behind 187 | 188 | # Return formatted status 189 | if [[ -z "$indicators" && "$clean" == "true" ]]; then 190 | echo "$branch ✓" # Clean repository 191 | elif [[ -n "$indicators" ]]; then 192 | echo "$branch ${indicators% }" # Branch with indicators 193 | else 194 | echo "$branch" # Just branch name 195 | fi 196 | } 197 | 198 | # ============================================================================ 199 | # FUNCTIONS: Formatting 200 | # ============================================================================ 201 | 202 | # Function: format_duration 203 | # Description: Converts milliseconds to human-readable duration. 204 | # Parameters: 205 | # $1 - milliseconds: Duration in milliseconds 206 | # Returns: 207 | # String like "2h 15m 30s", "5m 23s", or "45s" 208 | format_duration() { 209 | local milliseconds=$1 210 | local total_seconds=$((milliseconds / 1000)) 211 | 212 | # Calculate hours, minutes, seconds 213 | local hours=$((total_seconds / 3600)) 214 | local minutes=$(((total_seconds % 3600) / 60)) 215 | local seconds=$((total_seconds % 60)) 216 | 217 | local result="" 218 | 219 | # Build result string (only include non-zero components) 220 | [[ $hours -gt 0 ]] && result+="${hours}h " 221 | [[ $minutes -gt 0 ]] && result+="${minutes}m " 222 | [[ $seconds -gt 0 || -z "$result" ]] && result+="${seconds}s" # Always show seconds if nothing else 223 | 224 | # Remove trailing space 225 | echo "${result% }" 226 | } 227 | 228 | # Function: format_path 229 | # Description: Formats and truncates file paths for display. 230 | # Replaces home directory with ~, truncates long paths with ... 231 | # Parameters: 232 | # $1 - full_path: Complete file path 233 | # $2 - max_length: Maximum allowed length 234 | # Returns: 235 | # Formatted path like "~/Projects/MyApp" or ".../src/components" 236 | format_path() { 237 | local full_path="$1" 238 | local max_length=$2 239 | 240 | # Return "unknown" for empty paths 241 | [[ -z "$full_path" ]] && echo "unknown" && return 242 | 243 | local path="$full_path" 244 | 245 | # Replace home directory with ~ for brevity 246 | local home_dir="${HOME}" 247 | if [[ "$path" == "$home_dir"* ]]; then 248 | path="~${path#$home_dir}" 249 | fi 250 | 251 | # If path is short enough, return as-is 252 | if [[ ${#path} -le $max_length ]]; then 253 | echo "$path" 254 | return 255 | fi 256 | 257 | # Split path into parts 258 | IFS='/' read -ra parts <<< "$path" 259 | 260 | # Build truncated path from right to left (keep most recent directories) 261 | local final_parts=() 262 | local current_length=3 # Account for "..." 263 | 264 | # Iterate backwards through path components 265 | for ((i=${#parts[@]}; i>0; i--)); do 266 | local part="${parts[$i]}" 267 | local length_with_part=$((current_length + ${#part} + 1)) # +1 for separator 268 | 269 | # Add part if it fits within max length 270 | if [[ $length_with_part -le $max_length ]]; then 271 | final_parts=("$part" "${final_parts[@]}") 272 | current_length=$length_with_part 273 | else 274 | break # Stop when we exceed max length 275 | fi 276 | done 277 | 278 | # Build final truncated path with "..." prefix 279 | local result=".../" 280 | for part in "${final_parts[@]}"; do 281 | result+="$part/" 282 | done 283 | 284 | # Remove trailing slash 285 | echo "${result%/}" 286 | } 287 | 288 | # Function: get_model_name 289 | # Description: Formats model names for consistent display. 290 | # Applies title case and corrects known model names. 291 | # Parameters: 292 | # $1 - name: Raw model name from API 293 | # Returns: 294 | # Formatted name like "GLM 4.6", "Claude", "GPT", "DeepSeek R1" 295 | get_model_name() { 296 | local name="$1" 297 | 298 | # Return "unknown" for empty names 299 | [[ -z "$name" ]] && echo "unknown" && return 300 | 301 | # Apply title case (capitalize first letter of each word) 302 | name=$(echo "$name" | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2))}1') 303 | 304 | # Apply specific corrections for known model names (ordered from most specific to most generic) 305 | name="${name//Glm-4.6-V/GLM 4.6 V}" 306 | name="${name//Glm-4.6/GLM 4.6}" 307 | name="${name//Glm-4.5-Air/GLM 4.5 Air}" 308 | name="${name//Glm-4.5-V/GLM 4.5 V}" 309 | name="${name//Glm-4.5/GLM 4.5}" 310 | name="${name//Glm-4/GLM 4}" 311 | 312 | echo "$name" 313 | } 314 | 315 | # ============================================================================ 316 | # MAIN FUNCTION 317 | # ============================================================================ 318 | 319 | # Function: main 320 | # Description: Entry point. Reads JSON from stdin, parses it, and generates 321 | # a 2-line status display with directory, git, model, cost, and lines info. 322 | # Input: JSON from stdin (Claude Code CLI format) 323 | # Output: 2 lines of formatted status information 324 | main() { 325 | # Read JSON input from stdin 326 | local json_input 327 | json_input=$(cat) 328 | 329 | # Exit if no input provided 330 | if [[ -z "$json_input" ]]; then 331 | echo "No input data" 332 | return 333 | fi 334 | 335 | # Extract values from JSON using jq 336 | local full_directory=$(echo "$json_input" | jq -r '.workspace.current_dir // "unknown"') 337 | local model_name=$(echo "$json_input" | jq -r '.model.display_name // "unknown"') 338 | local cost_usd=$(echo "$json_input" | jq -r '.cost.total_cost_usd // 0') 339 | local duration_ms=$(echo "$json_input" | jq -r '.cost.total_duration_ms // 0') 340 | local api_duration_ms=$(echo "$json_input" | jq -r '.cost.total_api_duration_ms // 0') 341 | local lines_added=$(echo "$json_input" | jq -r '.cost.total_lines_added // 0') 342 | local lines_removed=$(echo "$json_input" | jq -r '.cost.total_lines_removed // 0') 343 | 344 | # Format directory path with truncation 345 | local directory_path=$(format_path "$full_directory" $MAX_PATH_LENGTH) 346 | 347 | # Get Git status and parse results 348 | local git_status=$(get_git_status "$full_directory") 349 | IFS='|' read -r branch staged modified untracked deleted conflict ahead behind clean <<< "$git_status" 350 | 351 | # Build Git info string (detailed or simple) 352 | local git_info 353 | if [[ "$SHOW_DETAILED_GIT" == true ]]; then 354 | git_info=$(build_git_indicators "$branch" "$staged" "$modified" "$untracked" "$deleted" "$conflict" "$ahead" "$behind" "$clean") 355 | else 356 | git_info="$branch" # Just branch name 357 | fi 358 | 359 | # Format model name 360 | local model=$(get_model_name "$model_name") 361 | 362 | # Format cost with 2 decimal places 363 | local formatted_cost=$(printf "\$%.2f USD" "$cost_usd") 364 | 365 | # Format session duration 366 | local session_duration=$(format_duration "$duration_ms") 367 | 368 | # Calculate net lines changed 369 | local net=$((lines_added - lines_removed)) 370 | local lines_info="+${lines_added}/-${lines_removed} (Net: ${net})" 371 | 372 | # Choose Git emoji based on repository status 373 | local git_emoji 374 | if [[ "$branch" == "No Git" ]]; then 375 | git_emoji="📦" # Package emoji for non-git directories 376 | else 377 | git_emoji="🍃" # Leaf emoji for git repositories 378 | fi 379 | 380 | # LINE 1: Directory | Git Status | Model 381 | local line1="🗂️ ${directory_path} | ${git_emoji} ${git_info} | 🤖 ${model}" 382 | 383 | # LINE 2: Cost | Duration | Lines 384 | local line2="💵 ${formatted_cost} | ⏱️ ${session_duration}" 385 | 386 | # Optionally append API duration 387 | if [[ "$SHOW_API_DURATION" == true ]]; then 388 | local api_duration_sec=$(echo "scale=1; $api_duration_ms / 1000" | bc) 389 | line2+=" (API: ${api_duration_sec}s)" 390 | fi 391 | 392 | # Append lines info 393 | line2+=" | ✏️ ${lines_info}" 394 | 395 | # Output both lines 396 | echo "$line1" 397 | echo "$line2" 398 | } 399 | 400 | # Execute main function 401 | main 402 | -------------------------------------------------------------------------------- /statusline.ps1: -------------------------------------------------------------------------------- 1 | #Requires -Version 7.0 2 | 3 | <# 4 | .SYNOPSIS 5 | StatusLine for Claude Code CLI. 6 | 7 | .DESCRIPTION 8 | Displays useful information in 2 lines: 9 | - Line 1: Path | Branch + Git Status | Model 10 | - Line 2: Cost USD | Session Duration | Lines +/- (Net: N) 11 | 12 | .NOTES 13 | Author: Bedolla 14 | Date: 2025-10-25 15 | Version: 1.0 16 | Requirements: PowerShell 7.0+, Git installed 17 | Compatibility: Windows, macOS, Linux 18 | #> 19 | 20 | # ============================================================================ 21 | # CONFIGURATION 22 | # ============================================================================ 23 | 24 | [bool]$SHOW_DETAILED_GIT = $true # Show detailed git indicators (staged, modified, etc.) 25 | [bool]$SHOW_API_DURATION = $false # Show API call duration in addition to total session duration 26 | [int]$MAX_PATH_LENGTH = 50 # Maximum characters for path before truncation 27 | 28 | # ============================================================================ 29 | # CLASS: GitStatus 30 | # ============================================================================ 31 | 32 | class GitStatus { 33 | [string]$Branch 34 | [int]$StagedFiles 35 | [int]$ModifiedFiles 36 | [int]$UntrackedFiles 37 | [int]$DeletedFiles 38 | [int]$ConflictFiles 39 | [int]$CommitsAhead 40 | [int]$CommitsBehind 41 | [bool]$IsClean 42 | 43 | GitStatus() { 44 | # Initialize all counters and status variables 45 | $this.Branch = "" 46 | $this.StagedFiles = 0 # Files in staging area (git add) 47 | $this.ModifiedFiles = 0 # Modified but not staged 48 | $this.UntrackedFiles = 0 # New files not tracked by git 49 | $this.DeletedFiles = 0 # Deleted files 50 | $this.ConflictFiles = 0 # Files with merge conflicts 51 | $this.CommitsAhead = 0 # Commits ahead of remote 52 | $this.CommitsBehind = 0 # Commits behind remote 53 | $this.IsClean = $false # True if no pending changes 54 | } 55 | 56 | # Method: Get Git status using git status --porcelain 57 | [void] GetStatus([string]$workspaceDir) { 58 | try { 59 | # Save current directory to restore later 60 | [string]$originalDir = Get-Location 61 | 62 | # Change to workspace directory if it exists 63 | if (-not [string]::IsNullOrWhiteSpace($workspaceDir) -and (Test-Path $workspaceDir)) { 64 | Set-Location $workspaceDir 65 | } 66 | 67 | # Execute git status with machine-readable format 68 | # --porcelain: Stable format for scripts 69 | # --branch: Include branch info in output 70 | [string[]]$lines = & git status --porcelain --branch 2>$null 71 | 72 | # Return to original directory 73 | Set-Location $originalDir 74 | 75 | # If no output, not a git repository 76 | if ($null -eq $lines -or $lines.Count -eq 0) { 77 | $this.Branch = "No Git" 78 | return 79 | } 80 | 81 | # Extract branch information (first line starting with ##) 82 | [string]$branchLine = $lines | Where-Object { $_.StartsWith("##") } | Select-Object -First 1 83 | if ($branchLine) { 84 | # Extract branch name from line like: "## main...origin/main [ahead 1]" 85 | if ($branchLine -match "^## ([^\\.]+)") { 86 | $this.Branch = $matches[1] 87 | } 88 | elseif ($branchLine -match "^## (.+)") { 89 | $this.Branch = $matches[1] 90 | } 91 | 92 | # Extract commits ahead/behind remote (if any) 93 | if ($branchLine -match "ahead (\\d+)") { 94 | $this.CommitsAhead = [int]$matches[1] 95 | } 96 | if ($branchLine -match "behind (\\d+)") { 97 | $this.CommitsBehind = [int]$matches[1] 98 | } 99 | } 100 | 101 | # Process file status lines (exclude branch line) 102 | [string[]]$fileLines = $lines | Where-Object { -not $_.StartsWith("##") } 103 | 104 | # Parse each file status line 105 | # Format: XY filename (X=staging, Y=working tree) 106 | foreach ($line in $fileLines) { 107 | if ($line.Length -lt 2) { continue } 108 | 109 | # Extract status codes (first two characters) 110 | [char]$stagingCode = $line[0] # Staging area status 111 | [char]$workingTreeCode = $line[1] # Working tree status 112 | 113 | # Process staging area status (first column) 114 | # M=Modified, A=Added, R=Renamed, C=Copied, D=Deleted, U=Unmerged, ?=Untracked 115 | if ($stagingCode -eq 'M' -or $stagingCode -eq 'A' -or $stagingCode -eq 'R' -or $stagingCode -eq 'C') { 116 | $this.StagedFiles++ # Ready for commit 117 | } 118 | if ($stagingCode -eq 'D') { 119 | $this.StagedFiles++ # Deleted and staged 120 | $this.DeletedFiles++ 121 | } 122 | 123 | # Process working tree status (second column) 124 | if ($workingTreeCode -eq 'M') { 125 | $this.ModifiedFiles++ # Modified but not staged 126 | } 127 | if ($workingTreeCode -eq 'D') { 128 | $this.DeletedFiles++ # Deleted but not staged 129 | } 130 | 131 | # Untracked files - count individual files within folders 132 | if ($stagingCode -eq '?' -and $workingTreeCode -eq '?') { 133 | # If it's a folder (ends with /), count individual files inside 134 | if ($line.TrimEnd().EndsWith('/')) { 135 | [string]$folder = $line.Substring(3).TrimEnd('/') 136 | [string[]]$filesInFolder = & git ls-files --others --exclude-standard -- "$folder/" 2>$null 137 | if ($null -ne $filesInFolder -and $filesInFolder.Count -gt 0) { 138 | $this.UntrackedFiles += $filesInFolder.Count 139 | } 140 | } 141 | else { 142 | # It's an individual file 143 | $this.UntrackedFiles++ # New file not tracked 144 | } 145 | } 146 | 147 | # Detect merge conflicts (various patterns) 148 | if ($stagingCode -eq 'U' -or $workingTreeCode -eq 'U') { 149 | $this.ConflictFiles++ # Unmerged file 150 | } 151 | if ($stagingCode -eq 'A' -and $workingTreeCode -eq 'A') { 152 | $this.ConflictFiles++ # Both added 153 | } 154 | if ($stagingCode -eq 'D' -and $workingTreeCode -eq 'D') { 155 | $this.ConflictFiles++ # Both deleted 156 | } 157 | } 158 | 159 | # Determine if repository is clean (no pending changes) 160 | $this.IsClean = ( 161 | $this.StagedFiles -eq 0 -and 162 | $this.ModifiedFiles -eq 0 -and 163 | $this.UntrackedFiles -eq 0 -and 164 | $this.DeletedFiles -eq 0 -and 165 | $this.ConflictFiles -eq 0 -and 166 | $this.CommitsAhead -eq 0 -and 167 | $this.CommitsBehind -eq 0 168 | ) 169 | } 170 | catch { 171 | $this.Branch = "error" 172 | } 173 | } 174 | 175 | # Method: Build compact indicators string 176 | # Returns string with emoji indicators for each status type 177 | [string] BuildIndicators() { 178 | [System.Collections.Generic.List[string]]$indicators = @() 179 | 180 | if ($this.StagedFiles -gt 0) { 181 | # ✅ Staged files (ready for commit) 182 | $indicators.Add("$([char]::ConvertFromUtf32(0x2705)) $($this.StagedFiles)") # ✅ 183 | } 184 | if ($this.ModifiedFiles -gt 0) { 185 | # ✏️ Modified files (unstaged) 186 | $indicators.Add("$([char]::ConvertFromUtf32(0x270F))$([char]::ConvertFromUtf32(0xFE0F)) $($this.ModifiedFiles)") # ✏️ 187 | } 188 | if ($this.UntrackedFiles -gt 0) { 189 | # 📄 New files without tracking 190 | $indicators.Add("$([char]::ConvertFromUtf32(0x1F4C4)) $($this.UntrackedFiles)") # 📄 191 | } 192 | if ($this.DeletedFiles -gt 0) { 193 | # 🗑️ Deleted files 194 | $indicators.Add("$([char]::ConvertFromUtf32(0x1F5D1))$([char]::ConvertFromUtf32(0xFE0F)) $($this.DeletedFiles)") # 🗑️ 195 | } 196 | if ($this.ConflictFiles -gt 0) { 197 | # ⚠️ Files with conflicts 198 | $indicators.Add("$([char]::ConvertFromUtf32(0x26A0))$([char]::ConvertFromUtf32(0xFE0F)) $($this.ConflictFiles)") # ⚠️ 199 | } 200 | if ($this.CommitsAhead -gt 0) { 201 | # ⬆️ Commits ahead of remote 202 | $indicators.Add("$([char]::ConvertFromUtf32(0x2B06))$([char]::ConvertFromUtf32(0xFE0F)) $($this.CommitsAhead)") # ⬆️ 203 | } 204 | if ($this.CommitsBehind -gt 0) { 205 | # ⬇️ Commits behind remote 206 | $indicators.Add("$([char]::ConvertFromUtf32(0x2B07))$([char]::ConvertFromUtf32(0xFE0F)) $($this.CommitsBehind)") # ⬇️ 207 | } 208 | 209 | # Return formatted status 210 | if ($indicators.Count -eq 0 -and $this.IsClean) { 211 | # ✓ Clean repository 212 | return "$([char]::ConvertFromUtf32(0x2713))" # ✓ 213 | } 214 | 215 | # Join all indicators with spaces 216 | return [string]::Join(" ", $indicators) 217 | } 218 | 219 | # Method: Get complete Git string (branch + indicators) 220 | # Returns branch name with status indicators 221 | [string] GetFullStatus() { 222 | [string]$indicators = $this.BuildIndicators() 223 | 224 | if ([string]::IsNullOrWhiteSpace($indicators)) { 225 | return $this.Branch # Just branch name 226 | } 227 | 228 | return "$($this.Branch) $indicators" # Branch with indicators 229 | } 230 | } 231 | 232 | # ============================================================================ 233 | # CLASS: DurationFormatter 234 | # ============================================================================ 235 | 236 | class DurationFormatter { 237 | # Static method: Format milliseconds to readable format (5m 23s, 2h 15m, etc.) 238 | static [string] FormatDuration([double]$milliseconds) { 239 | [int]$totalSeconds = [Math]::Floor($milliseconds / 1000) 240 | 241 | # Calculate hours, minutes, seconds 242 | [int]$hours = [Math]::Floor($totalSeconds / 3600) 243 | [int]$minutes = [Math]::Floor(($totalSeconds % 3600) / 60) 244 | [int]$seconds = $totalSeconds % 60 245 | 246 | [System.Collections.Generic.List[string]]$parts = @() 247 | 248 | # Build result string (only include non-zero components) 249 | if ($hours -gt 0) { 250 | $parts.Add("$($hours)h") 251 | } 252 | if ($minutes -gt 0) { 253 | $parts.Add("$($minutes)m") 254 | } 255 | if ($seconds -gt 0 -or $parts.Count -eq 0) { 256 | $parts.Add("$($seconds)s") # Always show seconds if nothing else 257 | } 258 | 259 | return [string]::Join(" ", $parts) 260 | } 261 | } 262 | 263 | # ============================================================================ 264 | # CLASS: PathFormatter 265 | # ============================================================================ 266 | 267 | class PathFormatter { 268 | # Static method: Format full path with truncation logic 269 | # Replaces home directory with ~, truncates long paths with ... 270 | static [string] FormatPath([string]$fullPath, [int]$maxLength) { 271 | # Return "unknown" for empty paths 272 | if ([string]::IsNullOrWhiteSpace($fullPath)) { 273 | return "unknown" 274 | } 275 | 276 | # Normalize path separators for current OS 277 | [string]$separator = [System.IO.Path]::DirectorySeparatorChar 278 | [string]$path = $fullPath.Replace('/', $separator).Replace('\\', $separator) 279 | 280 | # Replace home directory with ~ for brevity 281 | [string]$userProfile = if ($env:USERPROFILE) { $env:USERPROFILE } else { $env:HOME } 282 | if ($path.StartsWith($userProfile, [StringComparison]::OrdinalIgnoreCase)) { 283 | $path = "~" + $path.Substring($userProfile.Length) 284 | } 285 | 286 | # If path is short enough, return as-is 287 | if ($path.Length -le $maxLength) { 288 | return $path 289 | } 290 | 291 | # Split path into components 292 | [string[]]$parts = $path.Split([System.IO.Path]::DirectorySeparatorChar) 293 | 294 | # Build truncated path from right to left (keep most recent directories) 295 | [System.Collections.Generic.List[string]]$finalParts = @() 296 | [int]$currentLength = 3 # Account for "..." 297 | 298 | # Iterate backwards through path components 299 | for ([int]$i = $parts.Length - 1; $i -ge 0; $i--) { 300 | [string]$part = $parts[$i] 301 | [int]$lengthWithPart = $currentLength + $part.Length + 1 # +1 for separator 302 | 303 | # Add part if it fits within max length 304 | if ($lengthWithPart -le $maxLength) { 305 | $finalParts.Insert(0, $part) 306 | $currentLength = $lengthWithPart 307 | } 308 | else { 309 | break # Stop when we exceed max length 310 | } 311 | } 312 | 313 | # Build final truncated path with "..." prefix 314 | return "..." + [System.IO.Path]::DirectorySeparatorChar + [string]::Join([System.IO.Path]::DirectorySeparatorChar, $finalParts) 315 | } 316 | } 317 | 318 | # ============================================================================ 319 | # CLASS: StatusLineRenderer 320 | # ============================================================================ 321 | 322 | class StatusLineRenderer { 323 | [PSCustomObject]$InputData 324 | [bool]$ShowDetailedGit 325 | [bool]$ShowApiDuration 326 | [int]$MaxPathLength 327 | 328 | StatusLineRenderer([PSCustomObject]$data, [bool]$showGit, [bool]$showApi, [int]$maxLength) { 329 | $this.InputData = $data 330 | $this.ShowDetailedGit = $showGit 331 | $this.ShowApiDuration = $showApi 332 | $this.MaxPathLength = $maxLength 333 | } 334 | 335 | # Method: Get formatted directory path 336 | [string] GetDirectoryPath() { 337 | [string]$fullDirectory = $this.InputData.workspace.current_dir 338 | 339 | if ([string]::IsNullOrWhiteSpace($fullDirectory)) { 340 | return "unknown" 341 | } 342 | 343 | return [PathFormatter]::FormatPath($fullDirectory, $this.MaxPathLength) 344 | } 345 | 346 | # Method: Get Git information 347 | [string] GetGitInfo() { 348 | [string]$workspaceDir = $this.InputData.workspace.current_dir 349 | 350 | [GitStatus]$gitStatus = [GitStatus]::new() 351 | $gitStatus.GetStatus($workspaceDir) 352 | 353 | if ($this.ShowDetailedGit) { 354 | return $gitStatus.GetFullStatus() 355 | } 356 | else { 357 | return $gitStatus.Branch 358 | } 359 | } 360 | 361 | # Method: Get current model name 362 | # Formats model names for consistent display with title case and corrections 363 | [string] GetModelName() { 364 | [string]$modelName = $this.InputData.model.display_name 365 | 366 | # Return "unknown" for empty names 367 | if ([string]::IsNullOrWhiteSpace($modelName)) { 368 | return "unknown" 369 | } 370 | 371 | # Apply title case (capitalize first letter of each word) 372 | # Example: "glm-4.6" → "Glm-4.6", "claude-3.5-sonnet" → "Claude-3.5-Sonnet" 373 | $modelName = (Get-Culture).TextInfo.ToTitleCase($modelName.ToLower()) 374 | 375 | # Apply specific corrections for known model names (ordered from most specific to most generic) 376 | $modelName = $modelName.Replace("Glm-4.6-V", "GLM 4.6 V") 377 | $modelName = $modelName.Replace("Glm-4.6", "GLM 4.6") 378 | $modelName = $modelName.Replace("Glm-4.5-Air", "GLM 4.5 Air") 379 | $modelName = $modelName.Replace("Glm-4.5-V", "GLM 4.5 V") 380 | $modelName = $modelName.Replace("Glm-4.5", "GLM 4.5") 381 | $modelName = $modelName.Replace("Glm-4", "GLM 4") 382 | 383 | return $modelName 384 | } 385 | 386 | # Method: Get total cost in USD 387 | # Formats cost with 2 decimal places 388 | [string] GetCostUsd() { 389 | [double]$costUsd = 0.0 390 | 391 | if ($null -ne $this.InputData.cost.total_cost_usd) { 392 | $costUsd = [double]$this.InputData.cost.total_cost_usd 393 | } 394 | 395 | return "`$$($costUsd.ToString('F2')) USD" 396 | } 397 | 398 | # Method: Get formatted session duration 399 | [string] GetSessionDuration() { 400 | [double]$durationMs = 0.0 401 | 402 | if ($null -ne $this.InputData.cost.total_duration_ms) { 403 | $durationMs = [double]$this.InputData.cost.total_duration_ms 404 | } 405 | 406 | return [DurationFormatter]::FormatDuration($durationMs) 407 | } 408 | 409 | # Method: Get formatted API duration (optional) 410 | [string] GetApiDuration() { 411 | [double]$apiDurationMs = 0.0 412 | 413 | if ($null -ne $this.InputData.cost.total_api_duration_ms) { 414 | $apiDurationMs = [double]$this.InputData.cost.total_api_duration_ms 415 | } 416 | 417 | [double]$apiDurationSeconds = $apiDurationMs / 1000.0 418 | return "$($apiDurationSeconds.ToString('F1'))s" 419 | } 420 | 421 | # Method: Get code lines information 422 | # Returns formatted string with added/removed lines and net change 423 | [string] GetLinesInfo() { 424 | [int]$linesAdded = 0 425 | [int]$linesRemoved = 0 426 | 427 | if ($null -ne $this.InputData.cost.total_lines_added) { 428 | $linesAdded = [int]$this.InputData.cost.total_lines_added 429 | } 430 | if ($null -ne $this.InputData.cost.total_lines_removed) { 431 | $linesRemoved = [int]$this.InputData.cost.total_lines_removed 432 | } 433 | 434 | # Calculate net lines changed 435 | [int]$net = $linesAdded - $linesRemoved 436 | 437 | return "+$linesAdded/-$linesRemoved (Net: $net)" 438 | } 439 | 440 | # Method: Render complete StatusLine (2 lines) 441 | # Returns formatted 2-line status display 442 | [string] Render() { 443 | # Emojis with UTF-32 codes 444 | [string]$emojiFolder = [char]::ConvertFromUtf32(0x1F5C2) # 🗂️ 445 | [string]$emojiLeaf = [char]::ConvertFromUtf32(0x1F343) # 🍃 446 | [string]$emojiPackage = [char]::ConvertFromUtf32(0x1F4E6) # 📦 447 | [string]$emojiRobot = [char]::ConvertFromUtf32(0x1F916) # 🤖 448 | [string]$emojiMoney = [char]::ConvertFromUtf32(0x1F4B5) # 💵 449 | [string]$emojiClock = [char]::ConvertFromUtf32(0x23F1) # ⏱️ 450 | [string]$emojiPencil = [char]::ConvertFromUtf32(0x270F) # ✏️ 451 | 452 | # Gather all components for display 453 | [string]$directoryPath = $this.GetDirectoryPath() 454 | [string]$gitInfo = $this.GetGitInfo() 455 | [string]$modelName = $this.GetModelName() 456 | [string]$costUsd = $this.GetCostUsd() 457 | [string]$sessionDuration = $this.GetSessionDuration() 458 | [string]$linesInfo = $this.GetLinesInfo() 459 | 460 | # LINE 1: Directory | Git | Model 461 | # Choose Git emoji based on repository status 462 | [string]$gitEmoji = if ($gitInfo -eq "No Git") { $emojiPackage } else { $emojiLeaf } # 📦 or 🍃 463 | 464 | # Build line 1 with extra space after folder emoji for visual separation 465 | [string]$line1 = "$emojiFolder $directoryPath | $gitEmoji $gitInfo | $emojiRobot $modelName" 466 | 467 | # LINE 2: Cost | Duration | Lines 468 | # Build line 2 with extra space after clock and pencil emojis 469 | [string]$line2 = "$emojiMoney $costUsd | $emojiClock $sessionDuration" 470 | 471 | # Optionally append API duration 472 | if ($this.ShowApiDuration) { 473 | [string]$apiDuration = $this.GetApiDuration() 474 | $line2 += " (API: $apiDuration)" 475 | } 476 | 477 | # Append lines info 478 | $line2 += " | $emojiPencil $linesInfo" 479 | 480 | # Return 2 lines separated by newline 481 | return "$line1`n$line2" 482 | } 483 | } 484 | 485 | # ============================================================================ 486 | # MAIN FUNCTION 487 | # ============================================================================ 488 | 489 | # Main function: Entry point for the script 490 | # Reads JSON from stdin, parses it, and generates 2-line status display 491 | function Main { 492 | try { 493 | # Read JSON input from stdin (provided by Claude Code CLI) 494 | [string]$jsonInput = [Console]::In.ReadToEnd() 495 | 496 | # Exit if no input provided 497 | if ([string]::IsNullOrWhiteSpace($jsonInput)) { 498 | Write-Output "No input data" 499 | return 500 | } 501 | 502 | # Parse JSON into PowerShell object 503 | [PSCustomObject]$data = $jsonInput | ConvertFrom-Json 504 | 505 | # Create renderer with global configuration 506 | [StatusLineRenderer]$renderer = [StatusLineRenderer]::new( 507 | $data, 508 | $SHOW_DETAILED_GIT, 509 | $SHOW_API_DURATION, 510 | $MAX_PATH_LENGTH 511 | ) 512 | 513 | # Render and display 2-line output 514 | [string]$output = $renderer.Render() 515 | Write-Output $output 516 | } 517 | catch { 518 | # In case of error, show simple message 519 | Write-Output "Error in StatusLine: $($_.Exception.Message)" 520 | } 521 | } 522 | 523 | # Execute main function 524 | Main 525 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2025 Bedolla 2 | 3 | GNU AFFERO GENERAL PUBLIC LICENSE 4 | Version 3, 19 November 2007 5 | 6 | Copyright (C) 2007 Free Software Foundation, Inc. 7 | Everyone is permitted to copy and distribute verbatim copies 8 | of this license document, but changing it is not allowed. 9 | 10 | Preamble 11 | 12 | The GNU Affero General Public License is a free, copyleft license for 13 | software and other kinds of works, specifically designed to ensure 14 | cooperation with the community in the case of network server software. 15 | 16 | The licenses for most software and other practical works are designed 17 | to take away your freedom to share and change the works. By contrast, 18 | our General Public Licenses are intended to guarantee your freedom to 19 | share and change all versions of a program—to make sure it remains free 20 | software for all its users. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | Developers that use our General Public Licenses protect your rights 30 | with two steps: (1) assert copyright on the software, and (2) offer 31 | you this License which gives you legal permission to copy, distribute 32 | and/or modify the software. 33 | 34 | A secondary benefit of defending all users' freedom is that 35 | improvements made in alternate versions of the program, if they 36 | receive widespread use, become available for other developers to 37 | incorporate. Many developers of free software are heartened and 38 | encouraged by the resulting cooperation. However, in the case of 39 | software used on network servers, this result may fail to come about. 40 | The GNU General Public License permits making a modified version and 41 | letting the public access it on a server without ever releasing its 42 | source code to the public. 43 | 44 | The GNU Affero General Public License is designed specifically to 45 | ensure that, in such cases, the modified source code becomes available 46 | to the community. It requires the operator of a network server to 47 | provide the source code of the modified version running there to the 48 | users of that server. Therefore, public use of a modified version, on a 49 | publicly accessible server, gives the public access to the source code 50 | of the modified version. 51 | 52 | An older license, called the Affero General Public License and 53 | published by Affero, was designed to accomplish similar goals. This is 54 | a different license, not a version of the Affero GPL, but Affero has 55 | released a new version of the Affero GPL which permits relicensing 56 | under this license. 57 | 58 | The precise terms and conditions for copying, distribution and 59 | modification follow. 60 | 61 | TERMS AND CONDITIONS 62 | 63 | 0. Definitions. 64 | 65 | “This License” refers to version 3 of the GNU Affero General Public License. 66 | 67 | “Copyright” also means copyright-like laws that apply to other kinds of 68 | works, such as semiconductor masks. 69 | 70 | “The Program” refers to any copyrightable work licensed under this 71 | License. Each licensee is addressed as “you”. “Licensees” and 72 | “recipients” may be individuals or organizations. 73 | 74 | To “modify” a work means to copy from or adapt all or part of the work 75 | in a fashion requiring copyright permission, other than the making of an 76 | exact copy. The resulting work is called a “modified version” of the 77 | earlier work or a work “based on” the earlier work. 78 | 79 | A “covered work” means either the unmodified Program or a work based 80 | on the Program. 81 | 82 | To “propagate” a work means to do anything with it that, without 83 | permission, would make you directly or secondarily liable for 84 | infringement under applicable copyright law, except executing it on a 85 | computer or modifying a private copy. Propagation includes copying, 86 | distribution (with or without modification), making available to the 87 | public, and in some countries other activities as well. 88 | 89 | To “convey” a work means any kind of propagation that enables other 90 | parties to make or receive copies. Mere interaction with a user through 91 | a computer network, with no transfer of a copy, is not conveying. 92 | 93 | An interactive user interface displays “Appropriate Legal Notices” to 94 | the extent that it includes a convenient and prominently visible feature 95 | that (1) displays an appropriate copyright notice, and (2) tells the 96 | user that there is no warranty for the work (except to the extent that 97 | warranties are provided), that licensees may convey the work under this 98 | License, and how to view a copy of this License. If the interface 99 | presents a list of user commands or options, such as a menu, a 100 | prominent item in the list meets this criterion. 101 | 102 | 1. Source Code. 103 | 104 | The “source code” for a work means the preferred form of the work for 105 | making modifications to it. “Object code” means any non-source form of a 106 | work. 107 | 108 | A “Standard Interface” means an interface that either is an official 109 | standard defined by a recognized standards body, or, in the case of 110 | interfaces specified for a particular programming language, one that is 111 | widely used among developers working in that language. 112 | 113 | The “System Libraries” of an executable work include anything, other 114 | than the work as a whole, that (a) is included in the normal form of 115 | packaging a Major Component, but which is not part of that Major 116 | Component, and (b) serves only to enable use of the work with that 117 | Major Component, or to implement a Standard Interface for which an 118 | implementation is available to the public in source code form. A “Major 119 | Component”, in this context, means a major essential component (kernel, 120 | window system, and so on) of the specific operating system (if any) on 121 | which the executable work runs, or a compiler used to produce the work, 122 | or an object code interpreter used to run it. 123 | 124 | The “Corresponding Source” for a work in object code form means all the 125 | source code needed to generate, install, and (for an executable work) run 126 | the object code and to modify the work, including scripts to control 127 | those activities. However, it does not include the work's System 128 | Libraries, or general-purpose tools or generally available free programs 129 | which are used unmodified in performing those activities but which are 130 | not part of the work. For example, Corresponding Source includes 131 | interface definition files associated with source files for the work, 132 | and the source code for shared libraries and dynamically linked 133 | subprograms that the work is specifically designed to require, such as by 134 | intimate data communication or control flow between those subprograms 135 | and other parts of the work. 136 | 137 | The Corresponding Source need not include anything that users can 138 | regenerate automatically from other parts of the Corresponding Source. 139 | 140 | The Corresponding Source for a work in source code form is that same 141 | work. 142 | 143 | 2. Basic Permissions. 144 | 145 | All rights granted under this License are granted for the term of 146 | copyright on the Program, and are irrevocable provided the stated 147 | conditions are met. This License explicitly affirms your unlimited 148 | permission to run the unmodified Program. The output from running a 149 | covered work is covered by this License only if the output, given its 150 | content, constitutes a covered work. This License acknowledges your 151 | rights of fair use or other equivalent, as provided by copyright law. 152 | 153 | You may make, run and propagate covered works that you do not convey, 154 | without conditions so long as your license otherwise remains in force. 155 | You may convey covered works to others for the sole purpose of having 156 | them make modifications exclusively for you, or provide you with 157 | facilities for running those works, provided that you comply with the 158 | terms of this License in conveying all material for which you do not 159 | control copyright. Those thus making or running the covered works for 160 | you must do so exclusively on your behalf, under your direction and 161 | control, on terms that prohibit them from making any copies of your 162 | copyrighted material outside their relationship with you. 163 | 164 | Conveying under any other circumstances is permitted solely under the 165 | conditions stated below. Sublicensing is not allowed; section 10 makes 166 | it unnecessary. 167 | 168 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 169 | 170 | No covered work shall be deemed part of an effective technological 171 | measure under any applicable law fulfilling obligations under article 11 172 | of the WIPO copyright treaty adopted on 20 December 1996, or similar 173 | laws prohibiting or restricting circumvention of such measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to the 178 | covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; keep 188 | intact all notices stating that this License and any non-permissive terms 189 | added in accord with section 7 apply to the code; keep intact all notices 190 | of the absence of any warranty; and give all recipients a copy of this 191 | License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, and 194 | you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the terms 200 | of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | “keep intact all notices”. 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, in 226 | or on a volume of a storage or distribution medium, is called an 227 | “aggregate” if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work in 230 | an aggregate does not cause this License to apply to the other parts of 231 | the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms of 236 | sections 4 and 5, provided that you also convey the machine-readable 237 | Corresponding Source under the terms of this License, in one of these 238 | ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be included 283 | in conveying the object code work. 284 | 285 | A “User Product” is either (1) a “consumer product”, which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for 288 | incorporation into a dwelling. In determining whether a product is a 289 | consumer product, doubtful cases shall be resolved in favor of coverage. 290 | For a particular product received by a particular user, “normally used” 291 | refers to a typical or common use of that class of product, regardless of 292 | the status of the particular user or of the way in which the particular 293 | user actually uses, or expects or is expected to use, the product. A 294 | product is a consumer product regardless of whether the product has 295 | substantial commercial, industrial or non-consumer uses, unless such uses 296 | represent the only significant mode of use of the product. 297 | 298 | “Installation Information” for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied by 312 | the Installation Information. But this requirement does not apply if 313 | neither you nor any third party retains the ability to install modified 314 | object code on the User Product (for example, the work has been 315 | installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | “Additional permissions” are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by this 340 | License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option remove 343 | any additional permissions from that copy, or from any part of it. 344 | (Additional permissions may be written to require their own removal in 345 | certain cases when you modify the work.) You may place additional 346 | permissions on material, added by you to a covered work, for which you 347 | have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered “further 377 | restrictions” within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further restriction, 380 | you may remove that term. If a license document contains a further 381 | restriction but permits relicensing or conveying under this License, you 382 | may add to a covered work material governed by the terms of that license 383 | document, provided that the further restriction does not survive such 384 | relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you must 387 | place, in the relevant source files, a statement of the additional terms 388 | that apply to those files, or a notice indicating where to find the 389 | applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; the above 393 | requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your license 404 | from a particular copyright holder is reinstated (a) provisionally, 405 | unless and until the copyright holder explicitly and finally terminates 406 | your license, and (b) permanently, if the copyright holder fails to 407 | notify you of the violation by some reasonable means prior to 60 days 408 | after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is reinstated 411 | permanently if the copyright holder notifies you of the violation by 412 | some reasonable means, this is the first time you have received notice 413 | of violation of this License (for any work) from that copyright holder, 414 | and you cure the violation prior to 30 days after your receipt of the 415 | notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or run a 426 | copy of the Program. Ancillary propagation of a covered work occurring 427 | solely as a consequence of using peer-to-peer transmission to receive a 428 | copy likewise does not require acceptance. However, nothing other than 429 | this License grants you permission to propagate or modify any covered 430 | work. These actions infringe copyright if you do not accept this 431 | License. Therefore, by modifying or propagating a covered work, you 432 | indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An “entity transaction” is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that transaction 445 | who receives a copy of the work also receives whatever licenses to the 446 | work the party's predecessor in interest had or could give under the 447 | previous paragraph, plus a right to possession of the Corresponding 448 | Source of the work from the predecessor in interest, if the predecessor 449 | has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may not 453 | impose a license fee, royalty, or other charge for exercise of rights 454 | granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that any 456 | patent claim is infringed by making, using, selling, offering for sale, 457 | or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A “contributor” is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The work 463 | thus licensed is called the contributor's “contributor version”. 464 | 465 | A contributor's “essential patent claims” are all patent claims owned or 466 | controlled by the contributor, whether already acquired or hereafter 467 | acquired, that would be infringed by some manner, permitted by this 468 | License, of making, using, or selling its contributor version, but do 469 | not include claims that would be infringed only as a consequence of 470 | further modification of the contributor version. For purposes of this 471 | definition, “control” includes the right to grant patent sublicenses in a 472 | manner consistent with the requirements of this License. 473 | 474 | Each contributor grants you a non-exclusive, worldwide, royalty-free 475 | patent license under the contributor's essential patent claims, to make, 476 | use, sell, offer for sale, import and otherwise run, modify and 477 | propagate the contents of its contributor version. 478 | 479 | In the following three paragraphs, a “patent license” is any express 480 | agreement or commitment, however denominated, not to enforce a patent 481 | (such as an express permission to practice a patent or covenant not to 482 | sue for patent infringement). To “grant” such a patent license to a 483 | party means to make such an agreement or commitment not to enforce a 484 | patent against the party. 485 | 486 | If you convey a covered work, knowingly relying on a patent license, 487 | and the Corresponding Source of the work is not available for anyone to 488 | copy, free of charge and under the terms of this License, through a 489 | publicly available network server or other readily accessible means, 490 | then you must either (1) cause the Corresponding Source to be so 491 | available, or (2) arrange to deprive yourself of the benefit of the 492 | patent license for this particular work, or (3) arrange, in a manner 493 | consistent with the requirements of this License, to extend the patent 494 | license to downstream recipients. “Knowingly relying” means you have 495 | actual knowledge that, but for the patent license, your conveying the 496 | covered work in a country, or your recipient's use of the covered work 497 | in a country, would infringe one or more identifiable patents in that 498 | country that you have reason to believe are valid. 499 | 500 | If, pursuant to or in connection with a single transaction or 501 | arrangement, you convey, or propagate by procuring conveyance of, a 502 | covered work, and grant a patent license to some of the parties 503 | receiving the covered work authorizing them to use, propagate, modify or 504 | convey a specific copy of the covered work, then the patent license you 505 | grant is automatically extended to all recipients of the covered work 506 | and works based on it. 507 | 508 | A patent license is “discriminatory” if it does not include within the 509 | scope of its coverage, prohibits the exercise of, or is conditioned on 510 | the non-exercise of one or more of the rights that are specifically 511 | granted under this License. You may not convey a covered work if you are 512 | a party to an arrangement with a third party that is in the business of 513 | distributing software, under which you make payment to the third party 514 | based on the extent of your activity of conveying the work, and under 515 | which the third party grants, to any of the parties who would receive 516 | the covered work from you, a discriminatory patent license (a) in 517 | connection with copies of the covered work conveyed by you (or copies 518 | made from those copies), or (b) primarily for and in connection with 519 | specific products or compilations that contain the covered work, unless 520 | you entered into that arrangement, or that patent license was granted, 521 | prior to 28 March 2007. 522 | 523 | Nothing in this License shall be construed as excluding or limiting any 524 | implied license or other defenses to infringement that may otherwise be 525 | available to you under applicable patent law. 526 | 527 | 12. No Surrender of Others' Freedom. 528 | 529 | If conditions are imposed on you (whether by court order, agreement or 530 | otherwise) that contradict the conditions of this License, they do not 531 | excuse you from the conditions of this License. If you cannot convey a 532 | covered work so as to satisfy simultaneously your obligations under this 533 | License and any other pertinent obligations, then as a consequence you 534 | may not convey it at all. For example, if you agree to terms that obligate 535 | you to collect a royalty for further conveying from those to whom you 536 | convey the Program, the only way you could satisfy both those terms and 537 | this License would be to refrain entirely from conveying the Program. 538 | 539 | 13. Remote Network Interaction; Use with the GNU General Public License. 540 | 541 | Notwithstanding any other provision of this License, if you modify the 542 | Program, your modified version must prominently offer all users 543 | interacting with it remotely through a computer network (if your version 544 | supports such interaction) an opportunity to receive the Corresponding 545 | Source of your version by providing access to the Corresponding Source 546 | from a network server at no charge, through some standard or customary 547 | means of facilitating copying of software. This Corresponding Source 548 | shall include the Corresponding Source for any work covered by version 3 549 | of the GNU General Public License that is incorporated pursuant to the 550 | following paragraph. 551 | 552 | Notwithstanding any other provision of this License, you have permission 553 | to link or combine any covered work with a work licensed under version 3 554 | of the GNU General Public License into a single combined work, and to 555 | convey the resulting work. The terms of this License will continue to 556 | apply to the part which is the covered work, but the work with which it 557 | is combined will remain governed by version 3 of the GNU General Public 558 | License. 559 | 560 | 14. Revised Versions of this License. 561 | 562 | The Free Software Foundation may publish revised and/or new versions of 563 | the GNU Affero General Public License from time to time. Such new 564 | versions will be similar in spirit to the present version, but may 565 | differ in detail to address new problems or concerns. 566 | 567 | Each version is given a distinguishing version number. If the Program 568 | specifies that a certain numbered version of the GNU Affero General 569 | Public License “or any later version” applies to it, you have the 570 | option of following the terms and conditions either of that numbered 571 | version or of any later version published by the Free Software 572 | Foundation. If the Program does not specify a version number of the GNU 573 | Affero General Public License, you may choose any version ever published 574 | by the Free Software Foundation. 575 | 576 | If the Program specifies that a proxy can decide which future versions 577 | of the GNU Affero General Public License can be used, that proxy's 578 | public statement of acceptance of a version permanently authorizes you 579 | to choose that version for the Program. 580 | 581 | Later license versions may give you additional or different permissions. 582 | However, no additional obligations are imposed on any author or 583 | copyright holder as a result of your choosing to follow a later version. 584 | 585 | 15. Disclaimer of Warranty. 586 | 587 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 588 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 589 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY 590 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 591 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 592 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 593 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 594 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 595 | 596 | 16. Limitation of Liability. 597 | 598 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 599 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 600 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 601 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 602 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 603 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 604 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 605 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 606 | SUCH DAMAGES. 607 | 608 | 17. Interpretation of Sections 15 and 16. 609 | 610 | If the disclaimer of warranty and limitation of liability provided above 611 | cannot be given local legal effect according to their terms, reviewing 612 | courts shall apply local law that most closely approximates an absolute 613 | waiver of all civil liability in connection with the Program, unless a 614 | warranty or assumption of liability accompanies a copy of the Program in 615 | return for a fee. 616 | 617 | END OF TERMS AND CONDITIONS 618 | 619 | How to Apply These Terms to Your New Programs 620 | 621 | If you develop a new program, and you want it to be of the greatest 622 | possible use to the public, the best way to achieve this is to make it 623 | free software which everyone can redistribute and change under these terms. 624 | 625 | To do so, attach the following notices to the program. It is safest to 626 | attach them to the start of each source file to most effectively state 627 | the exclusion of warranty; and each file should have at least the 628 | “copyright” line and a pointer to where the full notice is found. 629 | 630 | 631 | Copyright (C) 632 | 633 | This program is free software: you can redistribute it and/or modify 634 | it under the terms of the GNU Affero General Public License as 635 | published by the Free Software Foundation, either version 3 of the 636 | License, or (at your option) any later version. 637 | 638 | This program is distributed in the hope that it will be useful, 639 | but WITHOUT ANY WARRANTY; without even the implied warranty of 640 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 641 | GNU Affero General Public License for more details. 642 | 643 | You should have received a copy of the GNU Affero General Public License 644 | along with this program. If not, see . 645 | 646 | Also add information on how to contact you by electronic and paper mail. 647 | 648 | If your software can interact with users remotely through a computer 649 | network, you should also make sure that it provides a way for users to 650 | get its source. For example, if your program is a web application, its 651 | interface could display a “Source” link that leads users to an archive 652 | of the code. There are many ways you could offer source, and different 653 | solutions will be better for different programs; see section 13 for the 654 | specific requirements. 655 | 656 | You should also get your employer (if you work as a programmer) or 657 | school, if any, to sign a “copyright disclaimer” for the program, if 658 | necessary. For more information on this, and how to apply and follow the 659 | GNU AGPL, see . -------------------------------------------------------------------------------- /zai.js: -------------------------------------------------------------------------------- 1 | // ============================================================================ 2 | // Z.AI TRANSFORMER FOR CLAUDE CODE ROUTER (PRODUCTION) 3 | // ============================================================================ 4 | // 5 | // PURPOSE: Claude Code Router Transformer for Z.ai's OpenAI-Compatible Endpoint 6 | // Solves Claude Code limitations and enables advanced features. 7 | // 8 | // FLOW: Claude Code → This Transformer → Z.AI OpenAI-Compatible Endpoint 9 | // 10 | // KEY FEATURES: 11 | // 12 | // 1. MAX OUTPUT TOKENS FIX (Primary Solution) 13 | // - Problem: Claude Code limits max_tokens to 32K/64K 14 | // - Solution: Transformer overrides to real model limits 15 | // • GLM 4.6: 128K (131,072 tokens) 16 | // • GLM 4.5: 96K (98,304 tokens) 17 | // • GLM 4.5-air: 96K (98,304 tokens) 18 | // • GLM 4.5v: 16K (16,384 tokens) 19 | // 20 | // 2. SAMPLING CONTROL (Guaranteed) 21 | // - Sets do_sample=true to ensure temperature and top_p always work 22 | // - Applies model-specific temperature and top_p values 23 | // 24 | // 3. REASONING CONTROL (Transformer-Managed) 25 | // - With default config (reasoning=true), transformer always controls reasoning 26 | // - Claude Code's native toggle (Tab key / alwaysThinkingEnabled) does NOT work 27 | // - To enable Claude Code control: Set all models to reasoning=false 28 | // - Translation: Transforms Claude Code reasoning → Z.AI thinking format 29 | // 30 | // 4. KEYWORD-BASED PROMPT ENHANCEMENT (Auto-Detection) 31 | // - Detects analytical keywords: analyze, calculate, count, explain, etc. 32 | // - Automatically adds reasoning instructions to user prompt 33 | // - REQUIRES: reasoning=true AND keywordDetection=true (both must be true) 34 | // - If either is false, keywords are ignored 35 | // 36 | // 5. ULTRATHINK MODE (User-Triggered) 37 | // - User types "ultrathink" anywhere in their message 38 | // - Enables enhanced reasoning with prompt optimization 39 | // - WORKS INDEPENDENTLY: Does NOT require reasoning or keywordDetection 40 | // - NOT AFFECTED by global overrides (works independently of settings) 41 | // - Highest precedence, always enabled when detected 42 | // 43 | // 6. GLOBAL CONFIGURATION OVERRIDES (Optional) 44 | // - Override settings across ALL models via options 45 | // - overrideMaxTokens: Override max_tokens globally 46 | // - overrideTemperature: Override temperature globally 47 | // - overrideTopP: Override top_p globally 48 | // - overrideReasoning: Override reasoning on/off globally 49 | // - overrideKeywordDetection: Override keyword detection globally 50 | // - customKeywords: Add or replace keyword list 51 | // - overrideKeywords: Use ONLY custom keywords (true) or add to defaults (false) 52 | // 53 | // 7. CUSTOM USER TAGS 54 | // - Tags: , 55 | // - Direct control over reasoning without modifying configuration 56 | // - IMPORTANT HIERARCHY: has HIGHER priority than 57 | // • alone → reasoning disabled 58 | // • + → reasoning enabled (Effort overrides) 59 | // • alone → reasoning enabled 60 | // 61 | // 8. FORCE PERMANENT THINKING (Level 0 - Maximum Priority) 62 | // - Option: forcePermanentThinking (in transformer options) 63 | // - Forces reasoning=true + effort=high on EVERY user message 64 | // - Overrides ALL other settings (Ultrathink, User Tags, Global Overrides, Model Config, Default) 65 | // - User Tags like , , are completely ignored 66 | // - Nuclear option: Use only when you want thinking 100% of the time with no way to disable it 67 | // 68 | // HIERARCHY: Force Permanent Thinking (0) > Ultrathink (1) > Custom Tags (2) > Global Override (3) > Model Config (4) > Claude Code (5) 69 | // NOTE: With default config (reasoning=true for all models), Level 4 applies model defaults. 70 | // Level 5 (Claude Code's native toggle) only works when: 71 | // - No user conditions (Levels 0-3) are active AND 72 | // - Model has reasoning=false in configuration 73 | // KEYWORDS: Requires reasoning=true + keywordDetection=true + keywords detected 74 | // 75 | // PRODUCTION: No debug logging, optimal performance 76 | // 77 | // CCR TYPE DEFINITIONS: 78 | // Based on: https://github.com/musistudio/llms/blob/main/src/types/llm.ts 79 | // https://github.com/musistudio/llms/blob/main/src/types/transformer.ts 80 | // 81 | // REFERENCES: 82 | // - CCR Transformer: https://github.com/musistudio/claude-code-router 83 | // - Z.AI Thinking: https://docs.z.ai/guides/overview/concept-param#thinking 84 | // ============================================================================ 85 | 86 | /** 87 | * Cache control settings for messages and content blocks 88 | * @typedef {Object} CacheControl 89 | * @property {string} type - Cache control type (e.g., "ephemeral") 90 | */ 91 | 92 | /** 93 | * Image URL container 94 | * @typedef {Object} ImageUrl 95 | * @property {string} url - The actual image URL (can be data URL or http/https) 96 | */ 97 | 98 | /** 99 | * Function call details 100 | * @typedef {Object} FunctionCallDetails 101 | * @property {string} name - Name of the function to call 102 | * @property {string} arguments - JSON string of function arguments 103 | */ 104 | 105 | /** 106 | * Thinking/reasoning content block from model 107 | * @typedef {Object} ThinkingBlock 108 | * @property {string} content - The thinking/reasoning text 109 | * @property {string} [signature] - Optional signature for thinking verification 110 | */ 111 | 112 | /** 113 | * Function parameters JSON Schema 114 | * @typedef {Object} FunctionParameters 115 | * @property {"object"} type - Always "object" for parameters root 116 | * @property {Object.} properties - Parameter definitions 117 | * @property {string[]} [required] - List of required parameter names 118 | * @property {boolean} [additionalProperties] - Allow additional properties 119 | * @property {string} [$schema] - JSON Schema version 120 | */ 121 | 122 | /** 123 | * Function definition 124 | * @typedef {Object} FunctionDefinition 125 | * @property {string} name - Function name (must be unique) 126 | * @property {string} description - Description of what the function does 127 | * @property {FunctionParameters} parameters - JSON Schema for function parameters 128 | */ 129 | 130 | /** 131 | * Reasoning configuration 132 | * @typedef {Object} ReasoningConfig 133 | * @property {ThinkLevel} [effort] - Reasoning effort level (OpenAI-style) 134 | * @property {number} [max_tokens] - Maximum tokens for reasoning (Anthropic-style) 135 | * @property {boolean} [enabled] - Whether reasoning is enabled 136 | */ 137 | 138 | /** 139 | * Transformer configuration item (object form) 140 | * @typedef {Object} TransformerConfigItem 141 | * @property {string} name - Transformer name 142 | * @property {Object} [options] - Transformer options 143 | */ 144 | 145 | /** 146 | * Transformer configuration 147 | * @typedef {Object} TransformerConfig 148 | * @property {string|string[]|TransformerConfigItem[]} use - Transformer name(s) or configuration(s) 149 | */ 150 | 151 | /** 152 | * Global overrides configuration 153 | * @typedef {Object} GlobalOverrides 154 | * @property {number|null} maxTokens - Override max_tokens for all models (takes precedence over model config) 155 | * @property {number|null} temperature - Override temperature for all models 156 | * @property {number|null} topP - Override top_p for all models 157 | * @property {boolean|null} reasoning - Override reasoning on/off for all models 158 | * @property {boolean|null} keywordDetection - Override automatic prompt enhancement on/off for all models 159 | */ 160 | 161 | /** 162 | * Text content block in a message 163 | * @typedef {Object} TextContent 164 | * @property {"text"} type - Content type identifier 165 | * @property {string} text - The actual text content 166 | * @property {CacheControl} [cache_control] - Optional cache control settings 167 | */ 168 | 169 | /** 170 | * Image content block in a message 171 | * @typedef {Object} ImageContent 172 | * @property {"image_url"} type - Content type identifier for images 173 | * @property {ImageUrl} image_url - Image URL container 174 | * @property {string} media_type - MIME type of the image (e.g., "image/png", "image/jpeg") 175 | */ 176 | 177 | /** 178 | * Union type for message content blocks 179 | * @typedef {TextContent | ImageContent} MessageContent 180 | */ 181 | 182 | /** 183 | * Tool/function call representation 184 | * @typedef {Object} ToolCall 185 | * @property {string} id - Unique identifier for this tool call 186 | * @property {"function"} type - Always "function" for function calls 187 | * @property {FunctionCallDetails} function - Function call details 188 | */ 189 | 190 | /** 191 | * Unified message format compatible with multiple LLM providers 192 | * @typedef {Object} UnifiedMessage 193 | * @property {"user"|"assistant"|"system"|"tool"} role - Message role in conversation 194 | * @property {string|null|MessageContent[]} content - Message content (string, null, or structured blocks) 195 | * @property {ToolCall[]} [tool_calls] - Tool/function calls made by assistant (OpenAI format - reserved for future compatibility) 196 | * @property {string} [tool_call_id] - ID of tool call this message is responding to for role="tool" (OpenAI format - reserved for future compatibility) 197 | * @property {CacheControl} [cache_control] - Cache control settings for this message 198 | * @property {ThinkingBlock} [thinking] - Reasoning/thinking content from model 199 | */ 200 | 201 | /** 202 | * Tool/function definition for LLM 203 | * @typedef {Object} UnifiedTool 204 | * @property {"function"} type - Always "function" for function tools 205 | * @property {FunctionDefinition} function - Function definition 206 | */ 207 | 208 | /** 209 | * Reasoning effort level (OpenAI o1-style) 210 | * @typedef {"low"|"medium"|"high"} ThinkLevel 211 | */ 212 | 213 | /** 214 | * @typedef {Object} UnifiedChatRequest 215 | * @property {UnifiedMessage[]} messages - Array of conversation messages 216 | * @property {string} model - LLM model name 217 | * @property {number} [max_tokens] - Maximum tokens in response 218 | * @property {number} [temperature] - Temperature for generation (0.0 - 2.0) 219 | * @property {number} [top_p] - Top-P nucleus sampling (0.0 - 1.0) 220 | * @property {boolean} [stream] - Whether response should be streamed 221 | * @property {UnifiedTool[]} [tools] - Available tools for the model 222 | * @property {"auto"|"none"|"required"|string|UnifiedTool} [tool_choice] - Tool selection strategy 223 | * @property {ReasoningConfig} [reasoning] - Reasoning configuration 224 | * @property {ThinkingConfiguration} [thinking] - Thinking configuration (provider-specific) 225 | */ 226 | 227 | /** 228 | * @typedef {Object} LLMProvider 229 | * @property {string} name - Provider name 230 | * @property {string} baseUrl - API base URL 231 | * @property {string} apiKey - API key 232 | * @property {string[]} models - Available models 233 | * @property {TransformerConfig} [transformer] - Transformer configuration 234 | */ 235 | 236 | /** 237 | * @typedef {Object} TransformerContext 238 | * @property {*} [key] - Additional context for transformer 239 | */ 240 | 241 | /** 242 | * Standard Fetch API Response (also available in Node.js 18+) 243 | * @typedef {Object} Response 244 | * @property {boolean} ok - Indicates if response was successful (status 200-299) 245 | * @property {number} status - HTTP status code 246 | * @property {string} statusText - HTTP status message 247 | * @property {Headers} headers - Response headers 248 | * @property {boolean} redirected - Indicates if response is result of redirect 249 | * @property {string} type - Response type (basic, cors, etc.) 250 | * @property {string} url - Response URL 251 | * @property {function(): Promise} arrayBuffer - Read body as ArrayBuffer 252 | * @property {function(): Promise} blob - Read body as Blob 253 | * @property {function(): Promise} formData - Read body as FormData 254 | * @property {function(): Promise} json - Read body as JSON 255 | * @property {function(): Promise} text - Read body as text 256 | * @property {ReadableStream} [body] - Body stream 257 | * @property {boolean} bodyUsed - Indicates if body has been read 258 | */ 259 | 260 | /** 261 | * Model-specific configuration 262 | * @typedef {Object} ModelConfig 263 | * @property {number} maxTokens - Maximum output tokens 264 | * @property {number|null} contextWindow - Maximum input tokens (context) 265 | * @property {number|null} temperature - Randomness control (0.0-2.0) 266 | * @property {number|null} topP - Nucleus sampling (0.0-1.0) 267 | * @property {boolean} reasoning - Whether model supports native reasoning (model decides when to use it) 268 | * @property {boolean} keywordDetection - Enable automatic prompt enhancement when analytical keywords are detected 269 | * @property {string} provider - Model provider (Z.AI only) 270 | */ 271 | 272 | /** 273 | * Request body to be modified by reasoning formatter 274 | * @typedef {Object} RequestBody 275 | * @property {*} [key] - Dynamic properties for the request body 276 | */ 277 | 278 | /** 279 | * Function that applies provider-specific reasoning format 280 | * @typedef {function(RequestBody, string): void} ReasoningFormatter 281 | * @param {RequestBody} body - Request body to modify 282 | * @param {string} modelName - Model name 283 | */ 284 | 285 | /** 286 | * Dictionary of model configurations indexed by model name 287 | * @typedef {Record} ModelConfigurationMap 288 | */ 289 | 290 | /** 291 | * Dictionary of reasoning formatters indexed by provider 292 | * @typedef {Record} ReasoningFormatterMap 293 | */ 294 | 295 | /** 296 | * Thinking/reasoning configuration for provider 297 | * @typedef {Object} ThinkingConfiguration 298 | * @property {string} type - Thinking type (e.g., "enabled") 299 | * @property {*} [key] - Additional provider-specific properties 300 | */ 301 | 302 | /** 303 | * Delta content in streaming response 304 | * @typedef {Object} StreamDelta 305 | * @property {string} [role] - Message role 306 | * @property {string} [content] - Content chunk 307 | * @property {string} [reasoning_content] - Reasoning/thinking content chunk 308 | * @property {string} [finish_reason] - Reason for completion 309 | */ 310 | 311 | /** 312 | * Choice in streaming response 313 | * @typedef {Object} StreamChoice 314 | * @property {StreamDelta} delta - Delta content 315 | * @property {number} index - Choice index 316 | */ 317 | 318 | /** 319 | * Modified request body to send to provider 320 | * @typedef {Object} ModifiedRequestBody 321 | * @property {string} model - Model name 322 | * @property {number} max_tokens - Maximum tokens 323 | * @property {number} [temperature] - Temperature setting 324 | * @property {number} [top_p] - Top-P setting 325 | * @property {boolean} [do_sample] - Sampling control 326 | * @property {UnifiedMessage[]} messages - Messages array 327 | * @property {ThinkingConfiguration} [thinking] - Thinking configuration 328 | * @property {StreamChoice[]} [choices] - Choices in response (for streaming) 329 | * @property {*} [key] - Additional dynamic properties 330 | */ 331 | 332 | /** 333 | * CCR Transformer interface (based on @musistudio/llms) 334 | * 335 | * @typedef {Object} CCRTransformer 336 | * @property {string} name - Unique transformer name (REQUIRED) 337 | * @property {function(UnifiedChatRequest, LLMProvider, TransformerContext): Promise} [transformRequestIn] - Transforms request before sending to provider 338 | * @property {function(Response): Promise} [transformResponseOut] - Converts response to unified format 339 | */ 340 | 341 | /** 342 | * Configuration options for transformer constructors 343 | * @typedef {Object} TransformerOptions 344 | * @property {boolean} [forcePermanentThinking] - Force reasoning=true + effort=high on EVERY user message (Level 0 - Maximum Priority) 345 | * @property {number} [overrideMaxTokens] - Override max_tokens globally for all models 346 | * @property {number} [overrideTemperature] - Override temperature globally for all models 347 | * @property {number} [overrideTopP] - Override top_p globally for all models 348 | * @property {boolean} [overrideReasoning] - Override reasoning on/off globally for all models 349 | * @property {boolean} [overrideKeywordDetection] - Override keyword detection globally for all models 350 | * @property {string[]} [customKeywords] - Custom keywords to add or replace default keywords 351 | * @property {boolean} [overrideKeywords] - If true, ONLY use customKeywords (ignore defaults); if false, add to defaults 352 | * @property {*} [key] - Allows any additional option 353 | */ 354 | 355 | /** 356 | * Transformer constructor with static name 357 | * @typedef {Object} TransformerConstructor 358 | * @property {string} [TransformerName] - Static transformer name (alternative to name property) 359 | */ 360 | 361 | /** 362 | * Z.ai Transformer for Claude Code Router. 363 | * Translates Claude Code reasoning format to Z.AI-specific format. 364 | * 365 | * @class 366 | * @implements {CCRTransformer} 367 | */ 368 | class ZaiTransformer { 369 | /** 370 | * Transformer name (required by CCR) 371 | * @type {string} 372 | */ 373 | name = "zai"; 374 | 375 | /** 376 | * Constructor 377 | * @param {TransformerOptions} options - Configuration options 378 | */ 379 | constructor (options) { 380 | /** 381 | * Configuration options 382 | * @type {TransformerOptions} 383 | */ 384 | this.options = options || {}; 385 | 386 | /** 387 | * Default maximum output tokens (fallback for unknown models) 388 | * @type {number} 389 | */ 390 | this.defaultMaxTokens = 131072; // 128K default 391 | 392 | /** 393 | * Force Permanent Thinking - MAXIMUM PRIORITY (Level 0) 394 | * When enabled, forces reasoning=true + effort=high on EVERY user message. 395 | * Overrides ALL other settings including Ultrathink, User Tags, and Global Overrides. 396 | * 397 | * WARNING: This is the nuclear option. Use only when you want thinking 100% of the time. 398 | * 399 | * @type {boolean} 400 | */ 401 | this.forcePermanentThinking = this.options.forcePermanentThinking === true; 402 | 403 | /** 404 | * Global overrides - Apply to ALL models when specified. 405 | * These have the highest priority and override model-specific settings. 406 | * 407 | * @type {GlobalOverrides} 408 | */ 409 | this.globalOverrides = { 410 | maxTokens: this.options.overrideMaxTokens != null ? this.options.overrideMaxTokens : null, 411 | temperature: this.options.overrideTemperature != null ? this.options.overrideTemperature : null, 412 | topP: this.options.overrideTopP != null ? this.options.overrideTopP : null, 413 | reasoning: this.options.overrideReasoning != null ? this.options.overrideReasoning : null, 414 | keywordDetection: this.options.overrideKeywordDetection != null ? this.options.overrideKeywordDetection : null 415 | }; 416 | 417 | /** 418 | * Model configurations by provider. 419 | * Defines maxTokens, contextWindow, temperature, topP, reasoning, keywordDetection, provider. 420 | * @type {ModelConfigurationMap} 421 | */ 422 | this.modelConfigurations = { 423 | // ===== Z.AI ===== 424 | 425 | // GLM 4.6 - Advanced reasoning with extended context 426 | 'glm-4.6': { 427 | maxTokens: 128 * 1024, // 131,072 (128K) 428 | contextWindow: 200 * 1024, // 204,800 (200K) 429 | temperature: 1.0, // Official value 430 | topP: 0.95, // Official value 431 | reasoning: true, // Supports native reasoning (model decides when to use it) 432 | keywordDetection: true, // Enable automatic prompt enhancement when analytical keywords detected 433 | provider: 'Z.AI' 434 | }, 435 | 436 | // GLM 4.5 - General purpose with reasoning 437 | 'glm-4.5': { 438 | maxTokens: 96 * 1024, // 98,304 (96K) 439 | contextWindow: 128 * 1024, // 131,072 (128K) 440 | temperature: 0.6, // Official value 441 | topP: 0.95, // Official value 442 | reasoning: true, // Supports native reasoning (model decides when to use it) 443 | keywordDetection: true, // Enable automatic prompt enhancement when analytical keywords detected 444 | provider: 'Z.AI' 445 | }, 446 | 447 | // GLM 4.5-air - Lightweight and fast version 448 | 'glm-4.5-air': { 449 | maxTokens: 96 * 1024, // 98,304 (96K) 450 | contextWindow: 128 * 1024, // 131,072 (128K) 451 | temperature: 0.6, // Official value 452 | topP: 0.95, // Official value 453 | reasoning: true, // Supports native reasoning (model decides when to use it) 454 | keywordDetection: true, // Enable automatic prompt enhancement when analytical keywords detected 455 | provider: 'Z.AI' 456 | }, 457 | 458 | // GLM 4.5v - For vision and multimodal 459 | 'glm-4.5v': { 460 | maxTokens: 16 * 1024, // 16,384 (16K) 461 | contextWindow: 128 * 1024, // 131,072 (128K) 462 | temperature: 0.6, // Official value 463 | topP: 0.95, // Official value 464 | reasoning: true, // Supports native reasoning (model decides when to use it) 465 | keywordDetection: true, // Enable automatic prompt enhancement when analytical keywords detected 466 | provider: 'Z.AI' 467 | }, 468 | 469 | // GLM 4.6v - Vision and multimodal with extended output 470 | 'glm-4.6v': { 471 | maxTokens: 32 * 1024, // 32,768 (32K) 472 | contextWindow: 128 * 1024, // 131,072 (128K) 473 | temperature: 0.6, // Official value 474 | topP: 0.95, // Official value 475 | reasoning: true, // Supports native reasoning (model decides when to use it) 476 | keywordDetection: true, // Enable automatic prompt enhancement when analytical keywords detected 477 | provider: 'Z.AI' 478 | } 479 | }; 480 | 481 | /** 482 | * Reasoning formats by provider. 483 | * Z.AI uses thinking, format: {type: "enabled"} 484 | * @type {ReasoningFormatterMap} 485 | */ 486 | this.reasoningFormatters = { 487 | 'Z.AI': (body, _modelName) => { 488 | body.thinking = { type: 'enabled' }; 489 | } 490 | }; 491 | 492 | /** 493 | * Keywords that trigger automatic prompt enhancement for analytical requests. 494 | * 495 | * Regular keywords require both reasoning=true AND keywordDetection=true. 496 | * If either is false, these keywords are ignored. 497 | * 498 | * The "ultrathink" keyword works independently of all settings and overrides. 499 | * It activates thinking and enhances prompt when detected. 500 | * 501 | * Customization: 502 | * - overrideKeywords=false (default): customKeywords are added to this default list 503 | * - overrideKeywords=true: only customKeywords are used, this list is ignored 504 | * 505 | * @type {string[]} 506 | */ 507 | const defaultKeywords = [ 508 | // Counting questions 509 | 'how many', 'how much', 'count', 'number of', 'total of', 'amount of', 510 | 511 | // Analysis and reasoning 512 | 'analyze', 'analysis', 'reason', 'reasoning', 'think', 'thinking', 513 | 'deduce', 'deduction', 'infer', 'inference', 514 | 515 | // Calculations and problem-solving 516 | 'calculate', 'calculation', 'solve', 'solution', 'determine', 517 | 518 | // Detailed explanations 519 | 'explain', 'explanation', 'demonstrate', 'demonstration', 520 | 'detail', 'detailed', 'step by step', 'step-by-step', 521 | 522 | // Identification and search 523 | 'identify', 'find', 'search', 'locate', 'enumerate', 'list', 524 | 525 | // Precision-requiring words 526 | 'letters', 'characters', 'digits', 'numbers', 'figures', 527 | 'positions', 'position', 'index', 'indices', 528 | 529 | // Comparisons and evaluations 530 | 'compare', 'comparison', 'evaluate', 'evaluation', 531 | 'verify', 'verification', 'check' 532 | ]; 533 | 534 | /** 535 | * Custom keywords provided by user via options.customKeywords 536 | * @type {string[]} 537 | */ 538 | const customKeywords = this.options.customKeywords || []; 539 | 540 | /** 541 | * If true, ONLY use customKeywords (ignore default keywords) 542 | * If false (default), ADD customKeywords to default keywords 543 | * @type {boolean} 544 | */ 545 | const overrideKeywords = this.options.overrideKeywords || false; 546 | 547 | /** 548 | * Final keyword list based on override setting 549 | * @type {string[]} 550 | */ 551 | this.keywords = overrideKeywords ? customKeywords : [...defaultKeywords, ...customKeywords]; 552 | } 553 | 554 | /** 555 | * Gets model-specific configuration 556 | * @param {string} modelName - Model name 557 | * @returns {ModelConfig} Model configuration or default values 558 | */ 559 | getModelConfiguration (modelName) { 560 | const config = this.modelConfigurations[modelName]; 561 | 562 | if (!config) { 563 | // If model not configured, use default values 564 | return { 565 | maxTokens: this.defaultMaxTokens, 566 | contextWindow: null, 567 | temperature: null, 568 | topP: null, 569 | reasoning: false, // Default: does NOT support reasoning 570 | keywordDetection: false, // Default: keyword detection disabled 571 | provider: 'Unknown' 572 | }; 573 | } 574 | 575 | return config; 576 | } 577 | 578 | /** 579 | * Detects if text contains keywords requiring reasoning 580 | * @param {string} text - Text to analyze 581 | * @returns {boolean} true if keywords detected 582 | */ 583 | detectReasoningNeeded (text) { 584 | if (!text) return false; 585 | 586 | const lowerText = text.toLowerCase(); 587 | return this.keywords.some(keyword => lowerText.includes(keyword)); 588 | } 589 | 590 | /** 591 | * Extracts text content from a message (handles both string and array formats) 592 | * @param {UnifiedMessage} message - Message to extract text from 593 | * @returns {string} Extracted text or empty string 594 | * @private 595 | */ 596 | _extractMessageText (message) { 597 | if (typeof message.content === 'string') { 598 | return message.content; 599 | } else if (Array.isArray(message.content)) { 600 | return message.content 601 | .filter(c => c.type === 'text' && c.text) 602 | .map(c => c.text) 603 | .join(' '); 604 | } 605 | return ''; 606 | } 607 | 608 | /** 609 | * Enhances prompt by adding reasoning instructions 610 | * @param {string} content - Original prompt content 611 | * @param {boolean} isUltrathink - If Ultrathink mode is active 612 | * @returns {string} Enhanced content with reasoning instructions 613 | */ 614 | modifyPromptForReasoning (content, isUltrathink = false) { 615 | let reasoningInstruction; 616 | 617 | if (isUltrathink) { 618 | // Ultrathink active: intensive instructions with explanation and memory warning 619 | reasoningInstruction = "\n\n[IMPORTANT: ULTRATHINK mode activated. (ULTRATHINK is the user's keyword requesting exceptionally thorough analysis from you as an AI model.) This means: DO NOT rely on your memory or assumptions - read and analyze everything carefully as if seeing it for the first time. Break down the problem step by step showing your complete reasoning, analyze each aspect meticulously by reading the actual current content (things may have changed since you last saw them), consider multiple perspectives and alternative approaches, verify logic coherence at each stage, and present well-founded conclusions with maximum level of detail based on what you actually read, not what you remember.]\n\n"; 620 | } else { 621 | // Normal mode: standard instructions 622 | reasoningInstruction = "\n\n[IMPORTANT: This question requires careful analysis. Think step by step and show your detailed reasoning before answering.]\n\n"; 623 | } 624 | 625 | return reasoningInstruction + content; 626 | } 627 | 628 | /** 629 | * Transforms request before sending to provider. 630 | * Applies model configuration, reasoning, and keywords. 631 | * 632 | * @param {UnifiedChatRequest} request - Claude Code request 633 | * @param {LLMProvider} [_provider] - LLM provider information (unused in production version) 634 | * @param {TransformerContext} [_context] - Context (unused in production version) 635 | * @returns {Promise} Optimized body for provider 636 | */ 637 | async transformRequestIn (request, _provider, _context) { 638 | const modelName = request.model || 'UNKNOWN'; 639 | const config = this.getModelConfiguration(modelName); 640 | 641 | // Apply max_tokens based on model configuration and global overrides 642 | // Claude Code has limitation of 32000/65537, we use actual model values 643 | // Global override has the highest priority 644 | // Use nullish coalescing (??) to allow 0 as valid override value 645 | const modifiedRequest = { 646 | ...request, 647 | max_tokens: this.globalOverrides.maxTokens ?? config.maxTokens 648 | }; 649 | 650 | // Detect custom tags in user messages 651 | // Priority: Force Permanent Thinking (0) > Ultrathink (1) > User Tags (2) > Global Override (3) > Model Config (4) > Claude Code (5) 652 | let ultrathinkDetected = false; 653 | let thinkingTag = null; // 'On', 'Off' 654 | let effortTag = null; // 'Low', 'Medium', 'High' 655 | 656 | // Search for tags in ALL user messages (most recent takes precedence) 657 | if (request.messages && Array.isArray(request.messages)) { 658 | for (let i = 0; i < request.messages.length; i++) { 659 | const message = request.messages[i]; 660 | if (message.role === 'user') { 661 | const messageText = this._extractMessageText(message); 662 | 663 | // Skip system-reminders 664 | if (messageText.trim().startsWith('')) { 665 | continue; 666 | } 667 | 668 | // Detect Ultrathink (case insensitive) 669 | if (/\bultrathink\b/i.test(messageText)) { 670 | ultrathinkDetected = true; 671 | } 672 | 673 | // Detect Thinking tags (English only) 674 | const thinkingMatch = messageText.match(//i); 675 | if (thinkingMatch) { 676 | thinkingTag = thinkingMatch[1]; // Capture: On, Off 677 | } 678 | 679 | // Detect Effort tags (English only) 680 | const effortMatch = messageText.match(//i); 681 | if (effortMatch) { 682 | effortTag = effortMatch[1]; // Capture: Low, Medium, High 683 | } 684 | } 685 | } 686 | } 687 | 688 | // Determine effective reasoning based on priority 689 | let effectiveReasoning = false; 690 | let effortLevel = "high"; // Default 691 | 692 | // 0. Force Permanent Thinking (MAXIMUM PRIORITY - Nuclear Option) 693 | if (this.forcePermanentThinking) { 694 | effectiveReasoning = true; 695 | effortLevel = "high"; 696 | } 697 | // 1. Ultrathink (highest priority, overrides EVERYTHING except forcePermanentThinking) 698 | else if (ultrathinkDetected) { 699 | effectiveReasoning = true; 700 | effortLevel = "high"; 701 | } 702 | // 2. User Tags 703 | else if (thinkingTag || effortTag) { 704 | // If there's a Thinking tag 705 | if (thinkingTag) { 706 | const thinkingLower = thinkingTag.toLowerCase(); 707 | if (thinkingLower === 'off') { 708 | // Thinking explicitly OFF 709 | // HIERARCHY: Effort tag has HIGHER priority than Thinking:Off 710 | // If effort tag present, it overrides Thinking:Off and enables reasoning 711 | effectiveReasoning = !!effortTag; 712 | } else if (thinkingLower === 'on') { 713 | // Thinking explicitly ON 714 | effectiveReasoning = true; 715 | } 716 | } 717 | // If NO thinking tag but there IS effort tag, assume reasoning ON 718 | else if (effortTag) { 719 | effectiveReasoning = true; 720 | } 721 | 722 | // Map effort tag to standard values (if exists) 723 | if (effortTag) { 724 | const effortLower = effortTag.toLowerCase(); 725 | if (effortLower === 'low') { 726 | effortLevel = "low"; 727 | } else if (effortLower === 'medium') { 728 | effortLevel = "medium"; 729 | } else if (effortLower === 'high') { 730 | effortLevel = "high"; 731 | } 732 | } 733 | // If thinking ON but no effort, use default "high" 734 | } 735 | // 3. Global Override 736 | else if (this.globalOverrides.reasoning !== null) { 737 | effectiveReasoning = this.globalOverrides.reasoning; 738 | effortLevel = "high"; 739 | } 740 | // 4. Model Config 741 | else if (config.reasoning === true) { 742 | effectiveReasoning = true; 743 | effortLevel = "high"; 744 | } 745 | 746 | // Remove tags from ALL user messages 747 | if (request.messages && Array.isArray(request.messages)) { 748 | let messagesModified = false; 749 | 750 | for (let i = 0; i < request.messages.length; i++) { 751 | const message = request.messages[i]; 752 | if (message.role === 'user') { 753 | let textModified = false; 754 | 755 | if (typeof message.content === 'string') { 756 | let newText = message.content; 757 | 758 | // Remove tags 759 | newText = newText.replace(//gi, ''); 760 | newText = newText.replace(//gi, ''); 761 | 762 | if (newText !== message.content) { 763 | textModified = true; 764 | if (!messagesModified) { 765 | modifiedRequest.messages = [...request.messages]; 766 | messagesModified = true; 767 | } 768 | modifiedRequest.messages[i] = { ...message, content: newText.trim() }; 769 | } 770 | } else if (Array.isArray(message.content)) { 771 | const newContent = message.content.map(content => { 772 | if (content.type === 'text' && content.text) { 773 | let newText = content.text; 774 | 775 | // Remove tags 776 | newText = newText.replace(//gi, ''); 777 | newText = newText.replace(//gi, ''); 778 | 779 | if (newText !== content.text) { 780 | textModified = true; 781 | return { ...content, text: newText.trim() }; 782 | } 783 | } 784 | return content; 785 | }); 786 | 787 | if (textModified) { 788 | if (!messagesModified) { 789 | modifiedRequest.messages = [...request.messages]; 790 | messagesModified = true; 791 | } 792 | modifiedRequest.messages[i] = { ...message, content: newContent }; 793 | } 794 | } 795 | } 796 | } 797 | } 798 | 799 | // Add reasoning field with effort level to request 800 | // Separate user-initiated conditions from model configuration 801 | const hasUserConditions = this.forcePermanentThinking || ultrathinkDetected || thinkingTag || effortTag || this.globalOverrides.reasoning !== null; 802 | 803 | if (hasUserConditions) { 804 | // User explicitly set reasoning (Levels 0-3): override everything 805 | if (effectiveReasoning) { 806 | modifiedRequest.reasoning = { 807 | enabled: true, 808 | effort: effortLevel 809 | }; 810 | 811 | // Apply provider thinking format 812 | const providerName = config.provider; 813 | if (this.reasoningFormatters[providerName]) { 814 | this.reasoningFormatters[providerName](modifiedRequest, modelName); 815 | } 816 | } else { 817 | modifiedRequest.reasoning = { 818 | enabled: false 819 | }; 820 | } 821 | } else if (config.reasoning === true) { 822 | // No user conditions but model supports reasoning (Level 4): use model default 823 | // effectiveReasoning is always true here (set in line 731) 824 | modifiedRequest.reasoning = { 825 | enabled: true, 826 | effort: effortLevel 827 | }; 828 | 829 | // Apply provider thinking format 830 | const providerName = config.provider; 831 | if (this.reasoningFormatters[providerName]) { 832 | this.reasoningFormatters[providerName](modifiedRequest, modelName); 833 | } 834 | } else { 835 | // No user conditions and model doesn't have reasoning config (Level 5): pass Claude Code's reasoning 836 | if (request.reasoning) { 837 | modifiedRequest.reasoning = request.reasoning; 838 | 839 | // If original reasoning is enabled, apply provider format 840 | if (request.reasoning.enabled === true) { 841 | const providerName = config.provider; 842 | if (this.reasoningFormatters[providerName]) { 843 | this.reasoningFormatters[providerName](modifiedRequest, modelName); 844 | } 845 | } 846 | } 847 | } 848 | 849 | // Add temperature (global override has priority) 850 | const finalTemperature = this.globalOverrides.temperature !== null 851 | ? this.globalOverrides.temperature 852 | : config.temperature; 853 | if (finalTemperature !== null) { 854 | modifiedRequest.temperature = finalTemperature; 855 | } 856 | 857 | // Add topP (global override has priority) 858 | const finalTopP = this.globalOverrides.topP !== null 859 | ? this.globalOverrides.topP 860 | : config.topP; 861 | if (finalTopP !== null) { 862 | modifiedRequest.top_p = finalTopP; 863 | } 864 | 865 | // Add do_sample to ensure temperature and top_p take effect 866 | modifiedRequest.do_sample = true; 867 | 868 | // Check if keywords are detected for prompt enhancement 869 | // ONLY if reasoning is active AND keywordDetection is enabled 870 | // Search in ALL valid messages, not just the last one 871 | let keywordsDetectedInConversation = false; 872 | if (request.messages && Array.isArray(request.messages)) { 873 | for (let i = 0; i < request.messages.length; i++) { 874 | const message = request.messages[i]; 875 | 876 | // Only analyze user messages for keywords 877 | if (message.role === 'user') { 878 | // Extract text from message (can be string or array of contents) 879 | const messageText = this._extractMessageText(message); 880 | 881 | // Skip automatic Claude Code system-reminder messages 882 | if (messageText.trim().startsWith('')) { 883 | continue; // Skip to next message 884 | } 885 | 886 | // Detect analytical keywords in any valid message 887 | if (this.detectReasoningNeeded(messageText)) { 888 | keywordsDetectedInConversation = true; 889 | break; // Already detected, no need to continue searching 890 | } 891 | } 892 | } 893 | } 894 | 895 | // Apply prompt enhancement if keywords were detected 896 | if (request.messages && Array.isArray(request.messages) && keywordsDetectedInConversation) { 897 | // Apply global override for keywordDetection if set 898 | const finalKeywordDetection = this.globalOverrides.keywordDetection !== null 899 | ? this.globalOverrides.keywordDetection 900 | : config.keywordDetection; 901 | 902 | // Only enhance prompt if reasoning is active AND detection enabled 903 | if (effectiveReasoning && finalKeywordDetection) { 904 | // Search for last user message to enhance its prompt 905 | for (let i = request.messages.length - 1; i >= 0; i--) { 906 | const message = request.messages[i]; 907 | 908 | // Enhancement always targets user messages only 909 | if (message.role === 'user') { 910 | // Extract text from message 911 | const messageText = this._extractMessageText(message); 912 | 913 | // Skip system-reminders 914 | if (messageText.trim().startsWith('')) { 915 | continue; 916 | } 917 | 918 | // Modify the prompt of the last valid message 919 | // Safety check: Ensure messages array exists before cloning 920 | if (!request.messages || !Array.isArray(request.messages)) { 921 | // Skip enhancement if messages array is invalid 922 | break; 923 | } 924 | 925 | // If we already modified messages to remove tags, use that copy 926 | if (!modifiedRequest.messages) { 927 | modifiedRequest.messages = [...request.messages]; 928 | } 929 | const modifiedMessage = { ...modifiedRequest.messages[i] }; 930 | 931 | if (typeof modifiedMessage.content === 'string') { 932 | modifiedMessage.content = this.modifyPromptForReasoning(modifiedMessage.content, ultrathinkDetected); 933 | } else if (Array.isArray(modifiedMessage.content)) { 934 | modifiedMessage.content = modifiedMessage.content.map(content => { 935 | if (content.type === 'text' && content.text) { 936 | return { ...content, text: this.modifyPromptForReasoning(content.text, ultrathinkDetected) }; 937 | } 938 | return content; 939 | }); 940 | } 941 | 942 | modifiedRequest.messages[i] = modifiedMessage; 943 | 944 | // Already modified the last valid message, exit loop 945 | break; 946 | } 947 | } 948 | } 949 | } 950 | 951 | return modifiedRequest; 952 | } 953 | 954 | /** 955 | * Transforms response before sending to Claude Code. 956 | * 957 | * @param {Response} response - Response processed by CCR 958 | * @returns {Promise} Unmodified response 959 | */ 960 | async transformResponseOut (response) { 961 | return response; 962 | } 963 | } 964 | 965 | // Export class for CCR 966 | module.exports = ZaiTransformer; 967 | -------------------------------------------------------------------------------- /zai-debug.js: -------------------------------------------------------------------------------- 1 | // ============================================================================ 2 | // Z.AI TRANSFORMER FOR CLAUDE CODE ROUTER (DEBUG) 3 | // ============================================================================ 4 | // 5 | // PURPOSE: Claude Code Router Transformer for Z.ai's OpenAI-Compatible Endpoint 6 | // Solves Claude Code limitations and enables advanced features. 7 | // DEBUG VERSION: Complete logging and transformation tracking. 8 | // 9 | // FLOW: Claude Code → This Transformer → Z.AI OpenAI-Compatible Endpoint 10 | // 11 | // KEY FEATURES: 12 | // 13 | // 1. MAX OUTPUT TOKENS FIX (Primary Solution) 14 | // - Problem: Claude Code limits max_tokens to 32K/64K 15 | // - Solution: Transformer overrides to real model limits 16 | // • GLM 4.6: 128K (131,072 tokens) 17 | // • GLM 4.5: 96K (98,304 tokens) 18 | // • GLM 4.5-air: 96K (98,304 tokens) 19 | // • GLM 4.5v: 16K (16,384 tokens) 20 | // 21 | // 2. SAMPLING CONTROL (Guaranteed) 22 | // - Sets do_sample=true to ensure temperature and top_p always work 23 | // - Applies model-specific temperature and top_p values 24 | // 25 | // 3. REASONING CONTROL (Transformer-Managed) 26 | // - With default config (reasoning=true), transformer always controls reasoning 27 | // - Claude Code's native toggle (Tab key / alwaysThinkingEnabled) does NOT work 28 | // - To enable Claude Code control: Set all models to reasoning=false 29 | // - Translation: Transforms Claude Code reasoning → Z.AI thinking format 30 | // 31 | // 4. KEYWORD-BASED PROMPT ENHANCEMENT (Auto-Detection) 32 | // - Detects analytical keywords: analyze, calculate, count, explain, etc. 33 | // - Automatically adds reasoning instructions to user prompt 34 | // - REQUIRES: reasoning=true AND keywordDetection=true (both must be true) 35 | // - If either is false, keywords are ignored 36 | // 37 | // 5. ULTRATHINK MODE (User-Triggered) 38 | // - User types "ultrathink" anywhere in their message 39 | // - Enables enhanced reasoning with prompt optimization 40 | // - WORKS INDEPENDENTLY: Does NOT require reasoning or keywordDetection 41 | // - NOT AFFECTED by global overrides (works independently of settings) 42 | // - Highest precedence, always enabled when detected 43 | // 44 | // 6. GLOBAL CONFIGURATION OVERRIDES (Optional) 45 | // - Override settings across ALL models via options 46 | // - overrideMaxTokens: Override max_tokens globally 47 | // - overrideTemperature: Override temperature globally 48 | // - overrideTopP: Override top_p globally 49 | // - overrideReasoning: Override reasoning on/off globally 50 | // - overrideKeywordDetection: Override keyword detection globally 51 | // - customKeywords: Add or replace keyword list 52 | // - overrideKeywords: Use ONLY custom keywords (true) or add to defaults (false) 53 | // 54 | // 7. CUSTOM USER TAGS (Direct Control) 55 | // - Custom tags in messages: 56 | // - Effort tags: 57 | // - Direct control over reasoning without modifying configuration 58 | // - High priority: Overrides global overrides and model configuration 59 | // - IMPORTANT HIERARCHY: has HIGHER priority than 60 | // • alone → reasoning disabled 61 | // • + → reasoning enabled (Effort overrides) 62 | // • alone → reasoning enabled 63 | // 64 | // 8. FORCE PERMANENT THINKING (Level 0 - Maximum Priority) 65 | // - Option: forcePermanentThinking (in transformer options) 66 | // - Forces reasoning=true + effort=high on EVERY user message 67 | // - Overrides ALL other settings (Ultrathink, User Tags, Global Overrides, Model Config, Default) 68 | // - User Tags like , , are completely ignored 69 | // - Nuclear option: Use only when you want thinking 100% of the time with no way to disable it 70 | // 71 | // REASONING HIERARCHY (6 Priority Levels): 72 | // Priority 0 (Maximum): Force Permanent Thinking → reasoning=true, effort=high (overrides EVERYTHING, including Ultrathink) 73 | // Priority 1 (Highest): Ultrathink → reasoning=true, effort=high (overrides all below) 74 | // Priority 2: Custom Tags (/) → Direct user control 75 | // Priority 3: Global Override (overrideReasoning) → Applied to all models 76 | // Priority 4: Model Configuration (config.reasoning) → Hardcoded per model (reasoning=true by default) 77 | // Priority 5: Claude Code → Only active when NO user conditions (0-3) AND model reasoning=false 78 | // 79 | // NOTE: With default config (reasoning=true for all models), Priority 4 applies model defaults. 80 | // Priority 5 (Claude Code's native toggle) only works when: 81 | // - No user conditions (Levels 0-3) are active AND 82 | // - Model has reasoning=false in configuration 83 | // 84 | // KEYWORD SYSTEM (Independent): 85 | // - REQUIRES 3 simultaneous conditions: reasoning=true + keywordDetection=true + keywords detected 86 | // - Automatic prompt enhancement when all 3 conditions met 87 | // - Works with any reasoning priority level (1-5) 88 | // 89 | // DEBUG FEATURES: 90 | // - Complete logging to ~/.claude-code-router/logs/zai-transformer-[timestamp].log 91 | // - Automatic rotation when file reaches size limit (default: 10 MB) 92 | // - Rotated files named: zai-transformer-[timestamp]-part[N].log 93 | // - Records all decisions and transformations 94 | // - Symbols: [SUCCESS], [ERROR], [WARNING], [INFO], [ENHANCEMENT], 95 | // [OMISSION], [NO CHANGES], [TRANSLATION], [APPLIED], [THINKING], [DO_SAMPLE] 96 | // 97 | // CCR TYPE DEFINITIONS: 98 | // Based on: https://github.com/musistudio/llms/blob/main/src/types/llm.ts 99 | // https://github.com/musistudio/llms/blob/main/src/types/transformer.ts 100 | // 101 | // REFERENCES: 102 | // - CCR Transformer: https://github.com/musistudio/claude-code-router 103 | // - Z.AI Thinking: https://docs.z.ai/guides/overview/concept-param#thinking 104 | // ============================================================================ 105 | 106 | const fs = require('fs'); 107 | const path = require('path'); 108 | const os = require('os'); 109 | 110 | /** 111 | * Cache control settings for messages and content blocks 112 | * @typedef {Object} CacheControl 113 | * @property {string} type - Cache control type (e.g., "ephemeral") 114 | */ 115 | 116 | /** 117 | * Image URL container 118 | * @typedef {Object} ImageUrl 119 | * @property {string} url - The actual image URL (can be data URL or http/https) 120 | */ 121 | 122 | /** 123 | * Function call details 124 | * @typedef {Object} FunctionCallDetails 125 | * @property {string} name - Name of the function to call 126 | * @property {string} arguments - JSON string of function arguments 127 | */ 128 | 129 | /** 130 | * Thinking/reasoning content block from model 131 | * @typedef {Object} ThinkingBlock 132 | * @property {string} content - The thinking/reasoning text 133 | * @property {string} [signature] - Optional signature for thinking verification 134 | */ 135 | 136 | /** 137 | * Function parameters JSON Schema 138 | * @typedef {Object} FunctionParameters 139 | * @property {"object"} type - Always "object" for parameters root 140 | * @property {Object.} properties - Parameter definitions 141 | * @property {string[]} [required] - List of required parameter names 142 | * @property {boolean} [additionalProperties] - Allow additional properties 143 | * @property {string} [$schema] - JSON Schema version 144 | */ 145 | 146 | /** 147 | * Function definition 148 | * @typedef {Object} FunctionDefinition 149 | * @property {string} name - Function name (must be unique) 150 | * @property {string} description - Description of what the function does 151 | * @property {FunctionParameters} parameters - JSON Schema for function parameters 152 | */ 153 | 154 | /** 155 | * Reasoning configuration 156 | * @typedef {Object} ReasoningConfig 157 | * @property {ThinkLevel} [effort] - Reasoning effort level (OpenAI-style) 158 | * @property {number} [max_tokens] - Maximum tokens for reasoning (Anthropic-style) 159 | * @property {boolean} [enabled] - Whether reasoning is enabled 160 | */ 161 | 162 | /** 163 | * Transformer configuration item (object form) 164 | * @typedef {Object} TransformerConfigItem 165 | * @property {string} name - Transformer name 166 | * @property {Object} [options] - Transformer options 167 | */ 168 | 169 | /** 170 | * Transformer configuration 171 | * @typedef {Object} TransformerConfig 172 | * @property {string|string[]|TransformerConfigItem[]} use - Transformer name(s) or configuration(s) 173 | */ 174 | 175 | /** 176 | * Global overrides configuration 177 | * @typedef {Object} GlobalOverrides 178 | * @property {number|null} maxTokens - Override max_tokens for all models (takes precedence over model config) 179 | * @property {number|null} temperature - Override temperature for all models 180 | * @property {number|null} topP - Override top_p for all models 181 | * @property {boolean|null} reasoning - Override reasoning on/off for all models 182 | * @property {boolean|null} keywordDetection - Override automatic prompt enhancement on/off for all models 183 | */ 184 | 185 | /** 186 | * Text content block in a message 187 | * @typedef {Object} TextContent 188 | * @property {"text"} type - Content type identifier 189 | * @property {string} text - The actual text content 190 | * @property {CacheControl} [cache_control] - Optional cache control settings 191 | */ 192 | 193 | /** 194 | * Image content block in a message 195 | * @typedef {Object} ImageContent 196 | * @property {"image_url"} type - Content type identifier for images 197 | * @property {ImageUrl} image_url - Image URL container 198 | * @property {string} media_type - MIME type of the image (e.g., "image/png", "image/jpeg") 199 | */ 200 | 201 | /** 202 | * Union type for message content blocks 203 | * @typedef {TextContent | ImageContent} MessageContent 204 | */ 205 | 206 | /** 207 | * Tool/function call representation 208 | * @typedef {Object} ToolCall 209 | * @property {string} id - Unique identifier for this tool call 210 | * @property {"function"} type - Always "function" for function calls 211 | * @property {FunctionCallDetails} function - Function call details 212 | */ 213 | 214 | /** 215 | * Unified message format compatible with multiple LLM providers 216 | * @typedef {Object} UnifiedMessage 217 | * @property {"user"|"assistant"|"system"|"tool"} role - Message role in conversation 218 | * @property {string|null|MessageContent[]} content - Message content (string, null, or structured blocks) 219 | * @property {ToolCall[]} [tool_calls] - Tool/function calls made by assistant (OpenAI format - reserved for future compatibility) 220 | * @property {string} [tool_call_id] - ID of tool call this message is responding to for role="tool" (OpenAI format - reserved for future compatibility) 221 | * @property {CacheControl} [cache_control] - Cache control settings for this message 222 | * @property {ThinkingBlock} [thinking] - Reasoning/thinking content from model 223 | */ 224 | 225 | /** 226 | * Tool/function definition for LLM 227 | * @typedef {Object} UnifiedTool 228 | * @property {"function"} type - Always "function" for function tools 229 | * @property {FunctionDefinition} function - Function definition 230 | */ 231 | 232 | /** 233 | * Reasoning effort level (OpenAI o1-style) 234 | * @typedef {"low"|"medium"|"high"} ThinkLevel 235 | */ 236 | 237 | /** 238 | * @typedef {Object} UnifiedChatRequest 239 | * @property {UnifiedMessage[]} messages - Array of conversation messages 240 | * @property {string} model - LLM model name 241 | * @property {number} [max_tokens] - Maximum tokens in response 242 | * @property {number} [temperature] - Temperature for generation (0.0 - 2.0) 243 | * @property {number} [top_p] - Top-P nucleus sampling (0.0 - 1.0) 244 | * @property {boolean} [stream] - Whether response should be streamed 245 | * @property {UnifiedTool[]} [tools] - Available tools for the model 246 | * @property {"auto"|"none"|"required"|string|UnifiedTool} [tool_choice] - Tool selection strategy 247 | * @property {ReasoningConfig} [reasoning] - Reasoning configuration 248 | * @property {ThinkingConfiguration} [thinking] - Thinking configuration (provider-specific) 249 | */ 250 | 251 | /** 252 | * @typedef {Object} LLMProvider 253 | * @property {string} name - Provider name 254 | * @property {string} baseUrl - API base URL 255 | * @property {string} apiKey - API key 256 | * @property {string[]} models - Available models 257 | * @property {TransformerConfig} [transformer] - Transformer configuration 258 | */ 259 | 260 | /** 261 | * @typedef {Object} TransformerContext 262 | * @property {*} [key] - Additional context for transformer 263 | */ 264 | 265 | /** 266 | * Standard Fetch API Response (also available in Node.js 18+) 267 | * @typedef {Object} Response 268 | * @property {boolean} ok - Indicates if response was successful (status 200-299) 269 | * @property {number} status - HTTP status code 270 | * @property {string} statusText - HTTP status message 271 | * @property {Headers} headers - Response headers 272 | * @property {boolean} redirected - Indicates if response is result of redirect 273 | * @property {string} type - Response type (basic, cors, etc.) 274 | * @property {string} url - Response URL 275 | * @property {function(): Promise} arrayBuffer - Read body as ArrayBuffer 276 | * @property {function(): Promise} blob - Read body as Blob 277 | * @property {function(): Promise} formData - Read body as FormData 278 | * @property {function(): Promise} json - Read body as JSON 279 | * @property {function(): Promise} text - Read body as text 280 | * @property {ReadableStream} [body] - Body stream 281 | * @property {boolean} bodyUsed - Indicates if body has been read 282 | */ 283 | 284 | /** 285 | * Model-specific configuration 286 | * @typedef {Object} ModelConfig 287 | * @property {number} maxTokens - Maximum output tokens 288 | * @property {number|null} contextWindow - Maximum input tokens (context) 289 | * @property {number|null} temperature - Randomness control (0.0-2.0) 290 | * @property {number|null} topP - Nucleus sampling (0.0-1.0) 291 | * @property {boolean} reasoning - Whether model supports native reasoning (model decides when to use it) 292 | * @property {boolean} keywordDetection - Enable automatic prompt enhancement when analytical keywords are detected 293 | * @property {string} provider - Model provider (Z.AI only) 294 | */ 295 | 296 | /** 297 | * Request body to be modified by reasoning formatter 298 | * @typedef {Object} RequestBody 299 | * @property {*} [key] - Dynamic properties for the request body 300 | */ 301 | 302 | /** 303 | * Function that applies provider-specific reasoning format 304 | * @typedef {function(RequestBody, string): void} ReasoningFormatter 305 | * @param {RequestBody} body - Request body to modify 306 | * @param {string} modelName - Model name 307 | */ 308 | 309 | /** 310 | * Dictionary of model configurations indexed by model name 311 | * @typedef {Record} ModelConfigurationMap 312 | */ 313 | 314 | /** 315 | * Dictionary of reasoning formatters indexed by provider 316 | * @typedef {Record} ReasoningFormatterMap 317 | */ 318 | 319 | /** 320 | * Thinking/reasoning configuration for provider 321 | * @typedef {Object} ThinkingConfiguration 322 | * @property {string} type - Thinking type (e.g., "enabled") 323 | * @property {*} [key] - Additional provider-specific properties 324 | */ 325 | 326 | /** 327 | * Delta content in streaming response 328 | * @typedef {Object} StreamDelta 329 | * @property {string} [role] - Message role 330 | * @property {string} [content] - Content chunk 331 | * @property {string} [reasoning_content] - Reasoning/thinking content chunk 332 | * @property {string} [finish_reason] - Reason for completion 333 | */ 334 | 335 | /** 336 | * Choice in streaming response 337 | * @typedef {Object} StreamChoice 338 | * @property {StreamDelta} delta - Delta content 339 | * @property {number} index - Choice index 340 | */ 341 | 342 | /** 343 | * Modified request body to send to provider 344 | * @typedef {Object} ModifiedRequestBody 345 | * @property {string} model - Model name 346 | * @property {number} max_tokens - Maximum tokens 347 | * @property {number} [temperature] - Temperature setting 348 | * @property {number} [top_p] - Top-P setting 349 | * @property {boolean} [do_sample] - Sampling control 350 | * @property {UnifiedMessage[]} messages - Messages array 351 | * @property {ThinkingConfiguration} [thinking] - Thinking configuration 352 | * @property {StreamChoice[]} [choices] - Choices in response (for streaming) 353 | * @property {*} [key] - Additional dynamic properties 354 | */ 355 | 356 | /** 357 | * CCR Transformer interface (based on @musistudio/llms) 358 | * 359 | * @typedef {Object} CCRTransformer 360 | * @property {string} name - Unique transformer name (REQUIRED) 361 | * @property {function(UnifiedChatRequest, LLMProvider, TransformerContext): Promise} [transformRequestIn] - Transforms request before sending to provider 362 | * @property {function(Response): Promise} [transformResponseOut] - Converts response to unified format 363 | */ 364 | 365 | /** 366 | * Configuration options for transformer constructors 367 | * @typedef {Object} TransformerOptions 368 | * @property {boolean} [forcePermanentThinking] - Force reasoning=true + effort=high on EVERY user message (Level 0 - Maximum Priority) 369 | * @property {number} [overrideMaxTokens] - Override max_tokens globally for all models 370 | * @property {number} [overrideTemperature] - Override temperature globally for all models 371 | * @property {number} [overrideTopP] - Override top_p globally for all models 372 | * @property {boolean} [overrideReasoning] - Override reasoning on/off globally for all models 373 | * @property {boolean} [overrideKeywordDetection] - Override keyword detection globally for all models 374 | * @property {string[]} [customKeywords] - Custom keywords to add or replace default keywords 375 | * @property {boolean} [overrideKeywords] - If true, ONLY use customKeywords (ignore defaults); if false, add to defaults 376 | * @property {number} [maxLogSize] - Maximum log file size before rotation (debug only, default: 10 MB) 377 | * @property {*} [key] - Allows any additional option 378 | */ 379 | 380 | /** 381 | * Transformer constructor with static name 382 | * @typedef {Object} TransformerConstructor 383 | * @property {string} [TransformerName] - Static transformer name (alternative to name property) 384 | */ 385 | 386 | /** 387 | * Z.ai Transformer for Claude Code Router. 388 | * Translates Claude Code reasoning format to Z.AI-specific format. 389 | * 390 | * @class 391 | * @implements {CCRTransformer} 392 | */ 393 | class ZaiTransformer { 394 | /** 395 | * Transformer name (required by CCR) 396 | * @type {string} 397 | */ 398 | name = "zai-debug"; 399 | 400 | /** 401 | * Constructor 402 | * @param {TransformerOptions} options - Configuration options 403 | */ 404 | constructor (options) { 405 | /** 406 | * Configuration options 407 | * @type {TransformerOptions} 408 | */ 409 | this.options = options || {}; 410 | 411 | /** 412 | * Default maximum output tokens (fallback for unknown models) 413 | * @type {number} 414 | */ 415 | this.defaultMaxTokens = 131072; // 128K default 416 | 417 | /** 418 | * Force Permanent Thinking - MAXIMUM PRIORITY (Level 0) 419 | * When enabled, forces reasoning=true + effort=high on EVERY user message. 420 | * Overrides ALL other settings including Ultrathink, User Tags, and Global Overrides. 421 | * 422 | * WARNING: This is the nuclear option. Use only when you want thinking 100% of the time. 423 | * 424 | * @type {boolean} 425 | */ 426 | this.forcePermanentThinking = this.options.forcePermanentThinking === true; 427 | 428 | /** 429 | * Global overrides - Apply to ALL models when specified. 430 | * These have the highest priority and override model-specific settings. 431 | * 432 | * @type {GlobalOverrides} 433 | */ 434 | this.globalOverrides = { 435 | maxTokens: this.options.overrideMaxTokens != null ? this.options.overrideMaxTokens : null, 436 | temperature: this.options.overrideTemperature != null ? this.options.overrideTemperature : null, 437 | topP: this.options.overrideTopP != null ? this.options.overrideTopP : null, 438 | reasoning: this.options.overrideReasoning != null ? this.options.overrideReasoning : null, 439 | keywordDetection: this.options.overrideKeywordDetection != null ? this.options.overrideKeywordDetection : null 440 | }; 441 | 442 | /** 443 | * Log buffer for asynchronous writing (avoids blocking event loop) 444 | * Protected against memory leaks: auto-flushes at 1000 items or every 100ms 445 | * @type {string[]} 446 | */ 447 | this.logBuffer = []; 448 | 449 | /** 450 | * Timeout for automatic log buffer flush 451 | * Cleared automatically on each flush to prevent memory leaks 452 | * @type {ReturnType|null} 453 | */ 454 | this.flushTimeout = null; 455 | 456 | /** 457 | * Maximum log file size before rotation (default: 10 MB) 458 | * @type {number} 459 | */ 460 | this.maxLogSize = this.options.maxLogSize || 10 * 1024 * 1024; // 10 MB 461 | 462 | /** 463 | * Session start timestamp (for log file names) 464 | * @type {string} 465 | */ 466 | this.sessionTimestamp = new Date().toISOString().replace(/:/g, '-').replace(/\..+/, ''); 467 | 468 | /** 469 | * Rotation counter for this session 470 | * @type {number} 471 | */ 472 | this.rotationCounter = 0; 473 | 474 | /** 475 | * Request counter to identify unique requests 476 | * Auto-resets to 1 when reaching Number.MAX_SAFE_INTEGER - 1000 for safety 477 | * @type {number} 478 | */ 479 | this.requestCounter = 0; 480 | 481 | /** 482 | * WeakSet to track which Response objects have been processed for stream reading 483 | * @type {WeakSet} 484 | */ 485 | this.processedResponses = new WeakSet(); 486 | 487 | /** 488 | * Response ID counter for unique response identification 489 | * Auto-resets to 1 when reaching Number.MAX_SAFE_INTEGER - 1000 for safety 490 | * @type {number} 491 | */ 492 | this.responseIdCounter = 0; 493 | 494 | /** 495 | * Model configurations by provider. 496 | * Defines maxTokens, contextWindow, temperature, topP, reasoning, keywordDetection, provider. 497 | * @type {ModelConfigurationMap} 498 | */ 499 | this.modelConfigurations = { 500 | // ===== Z.AI ===== 501 | 502 | // GLM 4.6 - Advanced reasoning with extended context 503 | 'glm-4.6': { 504 | maxTokens: 128 * 1024, // 131,072 (128K) 505 | contextWindow: 200 * 1024, // 204,800 (200K) 506 | temperature: 1.0, // Official value 507 | topP: 0.95, // Official value 508 | reasoning: true, // Supports native reasoning (model decides when to use it) 509 | keywordDetection: true, // Enable automatic prompt enhancement when analytical keywords detected 510 | provider: 'Z.AI' 511 | }, 512 | 513 | // GLM 4.5 - General purpose with reasoning 514 | 'glm-4.5': { 515 | maxTokens: 96 * 1024, // 98,304 (96K) 516 | contextWindow: 128 * 1024, // 131,072 (128K) 517 | temperature: 0.6, // Official value 518 | topP: 0.95, // Official value 519 | reasoning: true, // Supports native reasoning (model decides when to use it) 520 | keywordDetection: true, // Enable automatic prompt enhancement when analytical keywords detected 521 | provider: 'Z.AI' 522 | }, 523 | 524 | // GLM 4.5-air - Lightweight and fast version 525 | 'glm-4.5-air': { 526 | maxTokens: 96 * 1024, // 98,304 (96K) 527 | contextWindow: 128 * 1024, // 131,072 (128K) 528 | temperature: 0.6, // Official value 529 | topP: 0.95, // Official value 530 | reasoning: true, // Supports native reasoning (model decides when to use it) 531 | keywordDetection: true, // Enable automatic prompt enhancement when analytical keywords detected 532 | provider: 'Z.AI' 533 | }, 534 | 535 | // GLM 4.5v - For vision and multimodal 536 | 'glm-4.5v': { 537 | maxTokens: 16 * 1024, // 16,384 (16K) 538 | contextWindow: 128 * 1024, // 131,072 (128K) 539 | temperature: 0.6, // Official value 540 | topP: 0.95, // Official value 541 | reasoning: true, // Supports native reasoning (model decides when to use it) 542 | keywordDetection: true, // Enable automatic prompt enhancement when analytical keywords detected 543 | provider: 'Z.AI' 544 | }, 545 | 546 | // GLM 4.6v - Vision and multimodal with extended output 547 | 'glm-4.6v': { 548 | maxTokens: 32 * 1024, // 32,768 (32K) 549 | contextWindow: 128 * 1024, // 131,072 (128K) 550 | temperature: 0.6, // Official value 551 | topP: 0.95, // Official value 552 | reasoning: true, // Supports native reasoning (model decides when to use it) 553 | keywordDetection: true, // Enable automatic prompt enhancement when analytical keywords detected 554 | provider: 'Z.AI' 555 | } 556 | }; 557 | 558 | /** 559 | * Reasoning formats by provider. 560 | * Z.AI uses thinking, format: {type: "enabled"} 561 | * @type {ReasoningFormatterMap} 562 | */ 563 | this.reasoningFormatters = { 564 | 'Z.AI': (body, _modelName) => { 565 | body.thinking = { type: 'enabled' }; 566 | } 567 | }; 568 | 569 | /** 570 | * Keywords that trigger automatic prompt enhancement for analytical requests. 571 | * 572 | * Regular keywords require both reasoning=true AND keywordDetection=true. 573 | * If either is false, these keywords are ignored. 574 | * 575 | * The "ultrathink" keyword works independently of all settings and overrides. 576 | * It activates thinking and enhances prompt when detected. 577 | * 578 | * Customization Options: 579 | * - customKeywords: Array of additional keywords to add to the default list 580 | * - overrideKeywords: If true, ONLY customKeywords are used (ignores default list) 581 | * If false (default), customKeywords are added to default list 582 | * 583 | * Examples: 584 | * - customKeywords: ['design', 'plan'], overrideKeywords: false → adds to defaults 585 | * - customKeywords: ['design', 'plan'], overrideKeywords: true → replaces defaults 586 | * 587 | * @type {string[]} 588 | */ 589 | const defaultKeywords = [ 590 | // Counting questions 591 | 'how many', 'how much', 'count', 'number of', 'total of', 'amount of', 592 | 593 | // Analysis and reasoning 594 | 'analyze', 'analysis', 'reason', 'reasoning', 'think', 'thinking', 595 | 'deduce', 'deduction', 'infer', 'inference', 596 | 597 | // Calculations and problem-solving 598 | 'calculate', 'calculation', 'solve', 'solution', 'determine', 599 | 600 | // Detailed explanations 601 | 'explain', 'explanation', 'demonstrate', 'demonstration', 602 | 'detail', 'detailed', 'step by step', 'step-by-step', 603 | 604 | // Identification and search 605 | 'identify', 'find', 'search', 'locate', 'enumerate', 'list', 606 | 607 | // Precision-requiring words 608 | 'letters', 'characters', 'digits', 'numbers', 'figures', 609 | 'positions', 'position', 'index', 'indices', 610 | 611 | // Comparisons and evaluations 612 | 'compare', 'comparison', 'evaluate', 'evaluation', 613 | 'verify', 'verification', 'check' 614 | ]; 615 | 616 | // Build final keywords list based on customization options 617 | const customKeywords = this.options.customKeywords || []; 618 | const overrideKeywords = this.options.overrideKeywords || false; 619 | 620 | this.keywords = overrideKeywords ? customKeywords : [...defaultKeywords, ...customKeywords]; 621 | 622 | /** 623 | * Path to debug log file 624 | * Each session creates its own timestamped file: 625 | * ~/.claude-code-router/logs/zai-transformer-[timestamp].log 626 | * 627 | * Rotates automatically when reaching size limit (default: 10 MB) 628 | * Rotated files: zai-transformer-[timestamp]-part[N].log 629 | * @type {string} 630 | */ 631 | const logsDirectory = path.join(os.homedir(), '.claude-code-router', 'logs'); 632 | if (!fs.existsSync(logsDirectory)) { 633 | fs.mkdirSync(logsDirectory, { recursive: true }); 634 | } 635 | 636 | // Each session has its own timestamped file from the start 637 | this.logFile = path.join(logsDirectory, `zai-transformer-${this.sessionTimestamp}.log`); 638 | 639 | this.log('[START] Z.ai Transformer (Debug) initialized'); 640 | this.log(`[CONFIG] Log file: ${this.logFile}`); 641 | this.log(`[CONFIG] Maximum size per file: ${(this.maxLogSize / 1024 / 1024).toFixed(1)} MB`); 642 | } 643 | 644 | /** 645 | * Logs a message to console and file 646 | * 647 | * NOTE: CCR automatically provides a logger (winston/pino) after registerTransformer(). 648 | * If this.logger !== console, it means CCR has already provided it. 649 | * 650 | * To avoid blocking the event loop, messages are accumulated in a buffer 651 | * and written asynchronously every 100ms or at the end of the request. 652 | * 653 | * @param {string} message - Message to log 654 | */ 655 | log (message) { 656 | const line = `${message}\n`; 657 | console.log(line.trimEnd()); 658 | 659 | // Add to buffer instead of writing immediately 660 | this.logBuffer.push(line); 661 | 662 | // Force flush if buffer grows too large (prevent memory leaks) 663 | if (this.logBuffer.length > 1000) { 664 | this.flushLogs(); 665 | } 666 | 667 | // Schedule automatic flush if not already scheduled 668 | if (!this.flushTimeout) { 669 | this.flushTimeout = setTimeout(() => { 670 | this.flushLogs(); 671 | }, 100); // Flush every 100ms 672 | } 673 | } 674 | 675 | /** 676 | * Checks log file size and rotates if necessary 677 | * @private 678 | */ 679 | checkAndRotateLog () { 680 | try { 681 | if (fs.existsSync(this.logFile)) { 682 | const stats = fs.statSync(this.logFile); 683 | if (stats.size >= this.maxLogSize) { 684 | this.rotationCounter++; 685 | 686 | // Create new name with session timestamp + rotation counter 687 | const baseName = `zai-transformer-${this.sessionTimestamp}-part${this.rotationCounter}`; 688 | const rotatedPath = path.join(path.dirname(this.logFile), `${baseName}.log`); 689 | 690 | // Rename current log 691 | fs.renameSync(this.logFile, rotatedPath); 692 | const message = ` [LOG ROTATION] Size limit reached (${(stats.size / 1024 / 1024).toFixed(2)} MB) - Continuing in: ${path.basename(this.logFile)}`; 693 | console.log(message); 694 | // Create new file with continuation message (file was just renamed, so now it doesn't exist) 695 | fs.writeFileSync(this.logFile, `${message}\n [CONTINUATION] Log file part ${this.rotationCounter + 1}\n`); 696 | } 697 | } 698 | } catch (error) { 699 | // Ignore rotation errors (debug only) 700 | console.error(` [LOG ROTATION ERROR] ${error.message}`); 701 | } 702 | } 703 | 704 | /** 705 | * Writes log buffer to file asynchronously 706 | * @private 707 | */ 708 | flushLogs () { 709 | if (this.logBuffer.length === 0) return; 710 | 711 | const content = this.logBuffer.join(''); 712 | this.logBuffer = []; // Clear buffer 713 | this.flushTimeout = null; 714 | 715 | // Check if log rotation is needed before writing 716 | this.checkAndRotateLog(); 717 | 718 | // Asynchronous write (doesn't block event loop) 719 | fs.appendFile(this.logFile, content, (error) => { 720 | if (error) { 721 | console.error(` [LOG WRITE ERROR] ${error.message}`); 722 | } 723 | }); 724 | } 725 | 726 | /** 727 | * Safe JSON.stringify that handles circular references and limits depth 728 | * @param {any} obj - Object to serialize 729 | * @param {number} [maxDepth=3] - Maximum recursion depth 730 | * @param {string} [indent=''] - Additional indentation for each line 731 | * @returns {string} JSON string or error message 732 | */ 733 | safeJSON (obj, maxDepth = 3, indent = '') { 734 | try { 735 | const seen = new WeakSet(); 736 | 737 | const json = JSON.stringify(obj, (key, value) => { 738 | // Avoid circular references 739 | if (typeof value === 'object' && value !== null) { 740 | if (seen.has(value)) { 741 | return ' [Circular Reference]'; 742 | } 743 | seen.add(value); 744 | } 745 | return value; 746 | }, 2); 747 | 748 | // If no JSON (undefined, null), return 'undefined' as string 749 | if (json === undefined) { 750 | return 'undefined'; 751 | } 752 | if (json === null || json === 'null') { 753 | return 'null'; 754 | } 755 | 756 | // If indentation requested, add it to each line 757 | if (indent && json) { 758 | return json.split('\n').map((line, idx) => { 759 | // Don't indent first line (already has context indentation) 760 | return idx === 0 ? line : indent + line; 761 | }).join('\n'); 762 | } 763 | 764 | return json; 765 | } catch (error) { 766 | return ` [Serialization Error: ${error.message}]`; 767 | } 768 | } 769 | 770 | /** 771 | * Safely gets object keys 772 | * @param {any} obj - Object to inspect 773 | * @returns {string[]} Array of property names 774 | */ 775 | safeKeys (obj) { 776 | try { 777 | if (!obj || typeof obj !== 'object') return []; 778 | return Object.keys(obj); 779 | } catch (error) { 780 | return [' [Error getting keys]']; 781 | } 782 | } 783 | 784 | /** 785 | * Gets model-specific configuration 786 | * @param {string} modelName - Model name 787 | * @returns {ModelConfig} Model configuration or default values 788 | */ 789 | getModelConfiguration (modelName) { 790 | const config = this.modelConfigurations[modelName]; 791 | 792 | if (!config) { 793 | // If model not configured, use default values 794 | return { 795 | maxTokens: this.defaultMaxTokens, 796 | contextWindow: null, 797 | temperature: null, 798 | topP: null, 799 | reasoning: false, // Default: does NOT support reasoning 800 | keywordDetection: false, // Default: keyword detection disabled 801 | provider: 'Unknown' 802 | }; 803 | } 804 | 805 | return config; 806 | } 807 | 808 | /** 809 | * Detects if text contains keywords requiring reasoning 810 | * @param {string} text - Text to analyze 811 | * @returns {boolean} true if keywords detected 812 | */ 813 | detectReasoningNeeded (text) { 814 | if (!text) return false; 815 | 816 | const lowerText = text.toLowerCase(); 817 | return this.keywords.some(keyword => lowerText.includes(keyword)); 818 | } 819 | 820 | /** 821 | * Extracts text content from a message (handles both string and array formats) 822 | * @param {UnifiedMessage} message - Message to extract text from 823 | * @returns {string} Extracted text or empty string 824 | * @private 825 | */ 826 | _extractMessageText (message) { 827 | if (typeof message.content === 'string') { 828 | return message.content; 829 | } else if (Array.isArray(message.content)) { 830 | return message.content 831 | .filter(c => c.type === 'text' && c.text) 832 | .map(c => c.text) 833 | .join(' '); 834 | } 835 | return ''; 836 | } 837 | 838 | /** 839 | * Enhances prompt by adding reasoning instructions 840 | * @param {string} content - Original prompt content 841 | * @param {boolean} isUltrathink - If Ultrathink mode is active 842 | * @returns {string} Enhanced content with reasoning instructions 843 | */ 844 | modifyPromptForReasoning (content, isUltrathink = false) { 845 | let reasoningInstruction; 846 | 847 | if (isUltrathink) { 848 | // Ultrathink active: intensive instructions with explanation and memory warning 849 | reasoningInstruction = "\n\n[IMPORTANT: ULTRATHINK mode activated. (ULTRATHINK is the user's keyword requesting exceptionally thorough analysis from you as an AI model.) This means: DO NOT rely on your memory or assumptions - read and analyze everything carefully as if seeing it for the first time. Break down the problem step by step showing your complete reasoning, analyze each aspect meticulously by reading the actual current content (things may have changed since you last saw them), consider multiple perspectives and alternative approaches, verify logic coherence at each stage, and present well-founded conclusions with maximum level of detail based on what you actually read, not what you remember.]\n\n"; 850 | } else { 851 | // Normal mode: standard instructions 852 | reasoningInstruction = "\n\n[IMPORTANT: This question requires careful analysis. Think step by step and show your detailed reasoning before answering.]\n\n"; 853 | } 854 | 855 | return reasoningInstruction + content; 856 | } 857 | 858 | /** 859 | * Transforms request before sending to provider. 860 | * Applies model configuration, reasoning, and keywords. 861 | * 862 | * @param {UnifiedChatRequest} request - Claude Code request 863 | * @param {LLMProvider} provider - LLM provider information 864 | * @param {TransformerContext} context - Context (contains HTTP request) 865 | * @returns {Promise} Optimized body for provider 866 | */ 867 | async transformRequestIn (request, provider, context) { 868 | // Increment request counter and store current request ID 869 | this.requestCounter++; 870 | 871 | // Auto-reset counter when approaching maximum safe integer 872 | if (this.requestCounter >= Number.MAX_SAFE_INTEGER - 1000) { 873 | this.requestCounter = 1; 874 | } 875 | 876 | const currentRequestId = this.requestCounter; 877 | 878 | this.log(''); 879 | this.log('╔═══════════════════════════════════════════════════════════════════════════════════════════════════╗'); 880 | this.log(` [STAGE 1/3] INPUT: Claude Code → CCR → transformRequestIn() [Request #${currentRequestId}]`); 881 | this.log(' Request RECEIVED from Claude Code, BEFORE sending to provider'); 882 | this.log('╚═══════════════════════════════════════════════════════════════════════════════════════════════════╝'); 883 | 884 | // ======================================== 885 | // 1. LLM PROVIDER (final destination) 886 | // ======================================== 887 | this.log(''); 888 | this.log(' [PROVIDER] LLM destination information:'); 889 | if (provider) { 890 | this.log(` name: "${provider.name}"`); 891 | this.log(` baseUrl: "${provider.baseUrl}"`); 892 | this.log(` models: ${this.safeJSON(provider.models, 3, ' ')}`); 893 | if (provider.transformer && provider.transformer.use) { 894 | const transformerNames = Array.isArray(provider.transformer.use) 895 | ? provider.transformer.use.map(t => typeof t === 'string' ? t : t.name || 'unknown') 896 | : [provider.transformer.use]; 897 | this.log(` transformer: ${this.safeJSON(transformerNames, 3, ' ')}`); 898 | } 899 | } else { 900 | this.log(' [NOT PROVIDED]'); 901 | } 902 | 903 | // ======================================== 904 | // 2. HTTP CONTEXT 905 | // ======================================== 906 | this.log(''); 907 | this.log(' [CONTEXT] HTTP request from client:'); 908 | if (context && this.safeKeys(context).length > 0) { 909 | const contextKeys = this.safeKeys(context); 910 | contextKeys.forEach(key => { 911 | const value = context[key]; 912 | const type = typeof value; 913 | if (type === 'string' || type === 'number' || type === 'boolean') { 914 | this.log(` ${key}: ${value}`); 915 | } else if (type === 'object' && value !== null) { 916 | this.log(` ${key}: [${value.constructor?.name || 'Object'}]`); 917 | } 918 | }); 919 | } else { 920 | this.log(' [EMPTY]'); 921 | } 922 | 923 | // ======================================== 924 | // 3. REQUEST FROM CLAUDE CODE → CCR 925 | // ======================================== 926 | this.log(''); 927 | this.log(' [INPUT] Request received from Claude Code:'); 928 | this.log(` model: "${request.model}"`); 929 | this.log(` max_tokens: ${request.max_tokens !== undefined ? request.max_tokens : 'undefined'}`); 930 | this.log(` stream: ${request.stream}`); 931 | 932 | // Show message preview (roles and content length) - inline with other properties 933 | if (request.messages && request.messages.length > 0) { 934 | this.log(` messages: ${request.messages.length} messages`); 935 | request.messages.forEach((msg, idx) => { 936 | const role = msg.role || 'unknown'; 937 | 938 | // Extract real text from content (handle string or array) 939 | let textContent = ''; 940 | if (typeof msg.content === 'string') { 941 | textContent = msg.content; 942 | } else if (Array.isArray(msg.content)) { 943 | // Claude Code sends: [{type: "text", text: "..."}] 944 | textContent = msg.content 945 | .filter(item => item.type === 'text') 946 | .map(item => item.text) 947 | .join(' '); 948 | } else { 949 | textContent = JSON.stringify(msg.content || ''); 950 | } 951 | 952 | const contentLength = textContent.length; 953 | const preview = textContent.substring(0, 50).replace(/\n/g, ' '); 954 | this.log(` [${idx}] ${role}: ${contentLength} chars - "${preview}${contentLength > 50 ? '...' : ''}"`); 955 | }); 956 | } else { 957 | this.log(` messages: undefined`); 958 | } 959 | 960 | // Show tools with their names 961 | if (request.tools) { 962 | this.log(` tools: ${request.tools.length} tools`); 963 | const toolNames = request.tools.map((t, idx) => { 964 | if (t.function?.name) return t.function.name; 965 | if (t.name) return t.name; 966 | return `tool_${idx}`; 967 | }); 968 | this.log(` └─ [${toolNames.slice(0, 10).join(', ')}${request.tools.length > 10 ? `, ... +${request.tools.length - 10} more` : ''}]`); 969 | } else { 970 | this.log(` tools: undefined`); 971 | } 972 | 973 | this.log(` tool_choice: ${request.tool_choice !== undefined ? request.tool_choice : 'undefined'}`); 974 | this.log(` reasoning: ${this.safeJSON(request.reasoning, 3, ' ') || 'undefined'}`); 975 | this.log(''); 976 | 977 | // Extra properties 978 | const knownProperties = [ 979 | 'model', 'max_tokens', 'temperature', 'stream', 'messages', 980 | 'tools', 'tool_choice', 'reasoning' 981 | ]; 982 | const unknownProperties = this.safeKeys(request).filter(k => !knownProperties.includes(k)); 983 | if (unknownProperties.length > 0) { 984 | this.log(` [EXTRAS]: ${this.safeJSON(unknownProperties, 3, ' ')}`); 985 | } 986 | 987 | const modelName = request.model || 'UNKNOWN'; 988 | const config = this.getModelConfiguration(modelName); 989 | 990 | // Create copy of request with optimized parameters 991 | // Global override has the highest priority: globalOverrides.maxTokens ?? config.maxTokens ?? defaultMaxTokens 992 | // Use nullish coalescing (??) to allow 0 as valid override value 993 | const finalMaxTokens = this.globalOverrides.maxTokens ?? config.maxTokens; 994 | 995 | const modifiedRequest = { 996 | ...request, 997 | max_tokens: finalMaxTokens 998 | }; 999 | 1000 | // Log max_tokens setting (global override or model-specific) 1001 | if (this.globalOverrides.maxTokens) { 1002 | this.log(` [GLOBAL OVERRIDE] max_tokens: ${this.globalOverrides.maxTokens} (overrides model default)`); 1003 | } else if (request.max_tokens !== config.maxTokens) { 1004 | this.log(` [OVERRIDE] Original max_tokens: ${request.max_tokens} → Override to ${finalMaxTokens}`); 1005 | } 1006 | // max_tokens already set in line 984, no need to reassign 1007 | 1008 | // Detect custom tags in user messages 1009 | // Priority: Force Permanent Thinking (0) > Ultrathink (1) > User Tags (2) > Global Override (3) > Model Config (4) > Claude Code (5) 1010 | let ultrathinkDetected = false; 1011 | let thinkingTag = null; // 'On', 'Off' 1012 | let effortTag = null; // 'Low', 'Medium', 'High' 1013 | 1014 | this.log(''); 1015 | this.log(' [CUSTOM TAGS] Searching for tags in user messages...'); 1016 | 1017 | // Search for tags in ALL user messages (most recent takes precedence) 1018 | if (request.messages && Array.isArray(request.messages)) { 1019 | for (let i = 0; i < request.messages.length; i++) { 1020 | const message = request.messages[i]; 1021 | if (message.role === 'user') { 1022 | const messageText = this._extractMessageText(message); 1023 | 1024 | // Skip system-reminders 1025 | if (messageText.trim().startsWith('')) { 1026 | this.log(` [SYSTEM] Message ${i} ignored (system-reminder)`); 1027 | continue; 1028 | } 1029 | 1030 | // Detect Ultrathink (case insensitive) 1031 | if (/\bultrathink\b/i.test(messageText)) { 1032 | ultrathinkDetected = true; 1033 | this.log(` [TAG DETECTED] Ultrathink found in message ${i} (will be KEPT in message)`); 1034 | } 1035 | 1036 | // Detect Thinking tags (English only) 1037 | const thinkingMatch = messageText.match(//i); 1038 | if (thinkingMatch) { 1039 | thinkingTag = thinkingMatch[1]; // Capture: On, Off 1040 | this.log(` [TAG DETECTED] in message ${i}`); 1041 | } 1042 | 1043 | // Detect Effort tags (English only) 1044 | const effortMatch = messageText.match(//i); 1045 | if (effortMatch) { 1046 | effortTag = effortMatch[1]; // Capture: Low, Medium, High 1047 | this.log(` [TAG DETECTED] in message ${i}`); 1048 | } 1049 | } 1050 | } 1051 | } 1052 | 1053 | if (!ultrathinkDetected && !thinkingTag && !effortTag) { 1054 | this.log(' [INFO] No custom tags detected in messages'); 1055 | } 1056 | 1057 | // Determine effective reasoning based on priority 1058 | let effectiveReasoning = false; 1059 | let effortLevel = "high"; // Default 1060 | 1061 | this.log(''); 1062 | this.log(' [REASONING] Determining effective configuration...'); 1063 | 1064 | // 0. Force Permanent Thinking (MAXIMUM PRIORITY - Nuclear Option) 1065 | if (this.forcePermanentThinking) { 1066 | effectiveReasoning = true; 1067 | effortLevel = "high"; 1068 | this.log(' [PRIORITY 0] ⚠️ Force Permanent Thinking ACTIVE → reasoning=true, effort=high (MAXIMUM PRIORITY - overrides EVERYTHING)'); 1069 | } 1070 | // 1. Ultrathink (highest priority, overrides EVERYTHING except forcePermanentThinking) 1071 | else if (ultrathinkDetected) { 1072 | effectiveReasoning = true; 1073 | effortLevel = "high"; 1074 | this.log(' [PRIORITY 1] Ultrathink detected → reasoning=true, effort=high (highest priority)'); 1075 | } 1076 | // 2. User Tags 1077 | else if (thinkingTag || effortTag) { 1078 | this.log(' [PRIORITY 2] User Tags detected:'); 1079 | 1080 | // If there's a Thinking tag 1081 | if (thinkingTag) { 1082 | const thinkingLower = thinkingTag.toLowerCase(); 1083 | if (thinkingLower === 'off') { 1084 | // Thinking explicitly OFF 1085 | // HIERARCHY: Effort tag has HIGHER priority than Thinking:Off 1086 | // If effort tag present, it overrides Thinking:Off and enables reasoning 1087 | effectiveReasoning = !!effortTag; 1088 | if (effortTag) { 1089 | this.log(` but present → reasoning=true (Effort overrides Thinking:Off)`); 1090 | } else { 1091 | this.log(` → reasoning=false (explicitly disabled)`); 1092 | } 1093 | } else if (thinkingLower === 'on') { 1094 | // Thinking explicitly ON 1095 | effectiveReasoning = true; 1096 | this.log(` → reasoning=true`); 1097 | } 1098 | } 1099 | // If NO thinking tag but there IS effort tag, assume reasoning ON 1100 | else if (effortTag) { 1101 | effectiveReasoning = true; 1102 | this.log(` without Thinking tag → reasoning=true (effort implies reasoning)`); 1103 | } 1104 | 1105 | // Map effort tag to standard values (if exists) 1106 | if (effortTag) { 1107 | const effortLower = effortTag.toLowerCase(); 1108 | if (effortLower === 'low') { 1109 | effortLevel = "low"; 1110 | } else if (effortLower === 'medium') { 1111 | effortLevel = "medium"; 1112 | } else if (effortLower === 'high') { 1113 | effortLevel = "high"; 1114 | } 1115 | this.log(` Effort level mapped: ${effortTag} → ${effortLevel}`); 1116 | } else { 1117 | this.log(` No Effort tag, using default: ${effortLevel}`); 1118 | } 1119 | } 1120 | // 3. Global Override 1121 | else if (this.globalOverrides.reasoning !== null) { 1122 | effectiveReasoning = this.globalOverrides.reasoning; 1123 | effortLevel = "high"; 1124 | this.log(` [PRIORITY 3] Global Override: reasoning=${this.globalOverrides.reasoning} → reasoning=${effectiveReasoning}, effort=high`); 1125 | } 1126 | // 4. Model Config 1127 | else if (config.reasoning === true) { 1128 | effectiveReasoning = true; 1129 | effortLevel = "high"; 1130 | this.log(` [PRIORITY 4] Model config: reasoning=${config.reasoning} → reasoning=true, effort=high`); 1131 | } else { 1132 | this.log(` [DEFAULT] No tags, no global override, model config reasoning=${config.reasoning} → reasoning=false`); 1133 | } 1134 | 1135 | this.log(` [RESULT] Effective reasoning=${effectiveReasoning}, effort level=${effortLevel}`); 1136 | 1137 | // Remove tags from ALL user messages 1138 | this.log(''); 1139 | this.log(' [CLEANUP] Removing tags from messages...'); 1140 | let tagsRemovedCount = 0; 1141 | 1142 | if (request.messages && Array.isArray(request.messages)) { 1143 | let messagesModified = false; 1144 | 1145 | for (let i = 0; i < request.messages.length; i++) { 1146 | const message = request.messages[i]; 1147 | if (message.role === 'user') { 1148 | let textModified = false; 1149 | 1150 | if (typeof message.content === 'string') { 1151 | let newText = message.content; 1152 | const originalText = newText; 1153 | 1154 | // Remove tags 1155 | newText = newText.replace(//gi, ''); 1156 | newText = newText.replace(//gi, ''); 1157 | 1158 | if (newText !== originalText) { 1159 | textModified = true; 1160 | tagsRemovedCount++; 1161 | this.log(` Message ${i}: Tags removed`); 1162 | if (!messagesModified) { 1163 | modifiedRequest.messages = [...request.messages]; 1164 | messagesModified = true; 1165 | } 1166 | modifiedRequest.messages[i] = { ...message, content: newText.trim() }; 1167 | } 1168 | } else if (Array.isArray(message.content)) { 1169 | const newContent = message.content.map(content => { 1170 | if (content.type === 'text' && content.text) { 1171 | let newText = content.text; 1172 | 1173 | // Remove tags 1174 | newText = newText.replace(//gi, ''); 1175 | newText = newText.replace(//gi, ''); 1176 | 1177 | if (newText !== content.text) { 1178 | textModified = true; 1179 | return { ...content, text: newText.trim() }; 1180 | } 1181 | } 1182 | return content; 1183 | }); 1184 | 1185 | if (textModified) { 1186 | tagsRemovedCount++; 1187 | this.log(` Message ${i}: Tags removed (array content)`); 1188 | if (!messagesModified) { 1189 | modifiedRequest.messages = [...request.messages]; 1190 | messagesModified = true; 1191 | } 1192 | modifiedRequest.messages[i] = { ...message, content: newContent }; 1193 | } 1194 | } 1195 | } 1196 | } 1197 | } 1198 | 1199 | if (tagsRemovedCount === 0) { 1200 | this.log(' [INFO] No tags found to remove'); 1201 | } else { 1202 | this.log(` [COMPLETED] ${tagsRemovedCount} message(s) modified`); 1203 | } 1204 | 1205 | // Add reasoning field with effort level to request 1206 | // Separate user-initiated conditions from model configuration 1207 | this.log(''); 1208 | this.log(' [REASONING FIELD] Adding reasoning field to request...'); 1209 | const hasUserConditions = this.forcePermanentThinking || ultrathinkDetected || thinkingTag || effortTag || this.globalOverrides.reasoning !== null; 1210 | 1211 | if (hasUserConditions) { 1212 | // User explicitly set reasoning (Levels 0-3): override everything 1213 | this.log(` [INFO] User conditions detected (Levels 0-3), overriding reasoning`); 1214 | if (effectiveReasoning) { 1215 | modifiedRequest.reasoning = { 1216 | enabled: true, 1217 | effort: effortLevel 1218 | }; 1219 | this.log(` reasoning.enabled = true`); 1220 | this.log(` reasoning.effort = "${effortLevel}"`); 1221 | 1222 | // Apply provider thinking format 1223 | const providerName = config.provider; 1224 | if (this.reasoningFormatters[providerName]) { 1225 | this.reasoningFormatters[providerName](modifiedRequest, modelName); 1226 | this.log(` [THINKING] ${providerName} format applied`); 1227 | } else { 1228 | this.log(` [OMISSION] thinking NOT added (no formatter for ${providerName})`); 1229 | } 1230 | } else { 1231 | modifiedRequest.reasoning = { 1232 | enabled: false 1233 | }; 1234 | this.log(` reasoning.enabled = false`); 1235 | } 1236 | } else if (config.reasoning === true) { 1237 | // No user conditions but model supports reasoning (Level 4): use model default 1238 | // effectiveReasoning is always true here (set in line 1113) 1239 | this.log(` [INFO] No user conditions, using model configuration (Level 4)`); 1240 | modifiedRequest.reasoning = { 1241 | enabled: true, 1242 | effort: effortLevel 1243 | }; 1244 | this.log(` reasoning.enabled = true`); 1245 | this.log(` reasoning.effort = "${effortLevel}"`); 1246 | 1247 | // Apply provider thinking format 1248 | const providerName = config.provider; 1249 | if (this.reasoningFormatters[providerName]) { 1250 | this.reasoningFormatters[providerName](modifiedRequest, modelName); 1251 | this.log(` [THINKING] ${providerName} format applied`); 1252 | } else { 1253 | this.log(` [OMISSION] thinking NOT added (no formatter for ${providerName})`); 1254 | } 1255 | } else { 1256 | // No user conditions and model doesn't have reasoning config (Level 5): pass Claude Code's reasoning 1257 | if (request.reasoning) { 1258 | modifiedRequest.reasoning = request.reasoning; 1259 | this.log(` [INFO] No user conditions and no model config, passing Claude Code reasoning (Level 5)`); 1260 | this.log(` reasoning = ${JSON.stringify(request.reasoning)}`); 1261 | 1262 | // If original reasoning is enabled, apply provider format 1263 | if (request.reasoning.enabled === true) { 1264 | const providerName = config.provider; 1265 | if (this.reasoningFormatters[providerName]) { 1266 | this.reasoningFormatters[providerName](modifiedRequest, modelName); 1267 | this.log(` [THINKING] ${providerName} format applied for original reasoning`); 1268 | } else { 1269 | this.log(` [OMISSION] thinking NOT added (no formatter for ${providerName})`); 1270 | } 1271 | } 1272 | } else { 1273 | this.log(` [INFO] No conditions and no original reasoning (Level 5), field not added`); 1274 | } 1275 | } 1276 | 1277 | // Add temperature (global override takes priority) 1278 | const finalTemperature = this.globalOverrides.temperature !== null ? this.globalOverrides.temperature : config.temperature; 1279 | if (finalTemperature !== null) { 1280 | modifiedRequest.temperature = finalTemperature; 1281 | } 1282 | 1283 | // Add topP (global override takes priority) 1284 | const finalTopP = this.globalOverrides.topP !== null ? this.globalOverrides.topP : config.topP; 1285 | if (finalTopP !== null) { 1286 | modifiedRequest.top_p = finalTopP; 1287 | } 1288 | 1289 | // Add do_sample to ensure temperature and top_p take effect 1290 | modifiedRequest.do_sample = true; 1291 | 1292 | // Check if keywords are detected for prompt enhancement 1293 | // ONLY if reasoning is active AND keywordDetection is enabled 1294 | // Search in ALL user messages 1295 | this.log(''); 1296 | this.log(' [KEYWORDS] Checking for analytical keywords in ALL user messages...'); 1297 | 1298 | let keywordsDetectedInConversation = false; 1299 | let messageWithKeywords = -1; // Index of message containing keywords 1300 | 1301 | if (request.messages && Array.isArray(request.messages)) { 1302 | for (let i = 0; i < request.messages.length; i++) { 1303 | const message = request.messages[i]; 1304 | 1305 | // Only analyze user messages for keywords 1306 | if (message.role === 'user') { 1307 | // Extract text from message (can be string or array of contents) 1308 | const messageText = this._extractMessageText(message); 1309 | 1310 | // Skip automatic Claude Code system-reminder messages 1311 | if (messageText.trim().startsWith('')) { 1312 | this.log(` [MESSAGE ${i}] system-reminder ignored`); 1313 | continue; // Skip to next message 1314 | } 1315 | 1316 | // Detect analytical keywords in any valid message 1317 | const hasKeywords = this.detectReasoningNeeded(messageText); 1318 | const preview = messageText.substring(0, 50).replace(/\n/g, '↕'); 1319 | 1320 | if (hasKeywords) { 1321 | keywordsDetectedInConversation = true; 1322 | messageWithKeywords = i; 1323 | this.log(` [MESSAGE ${i}] ${message.role.toUpperCase()} - Keywords DETECTED: "${preview}..."`); 1324 | break; // Already detected, no need to continue searching 1325 | } else { 1326 | this.log(` [MESSAGE ${i}] ${message.role.toUpperCase()} - No keywords: "${preview}..."`); 1327 | } 1328 | } 1329 | } 1330 | } 1331 | 1332 | if (!keywordsDetectedInConversation) { 1333 | this.log(' [RESULT] No keywords detected in any message'); 1334 | } else { 1335 | this.log(` [RESULT] Keywords detected in message ${messageWithKeywords}`); 1336 | } 1337 | 1338 | // Apply prompt enhancement if keywords were detected 1339 | if (request.messages && Array.isArray(request.messages) && keywordsDetectedInConversation) { 1340 | // Apply global override for keywordDetection if set 1341 | const finalKeywordDetection = this.globalOverrides.keywordDetection !== null 1342 | ? this.globalOverrides.keywordDetection 1343 | : config.keywordDetection; 1344 | 1345 | this.log(` [CONFIGURATION] reasoning=${effectiveReasoning} | keywordDetection=${finalKeywordDetection}${this.globalOverrides.keywordDetection !== null ? ' (GLOBAL)' : ''}`); 1346 | 1347 | // Only enhance prompt if reasoning is active AND detection enabled 1348 | if (effectiveReasoning && finalKeywordDetection) { 1349 | this.log(' [ENHANCEMENT] Conditions met, searching for last valid message to enhance...'); 1350 | 1351 | // Search for last user message to enhance its prompt 1352 | for (let i = request.messages.length - 1; i >= 0; i--) { 1353 | const message = request.messages[i]; 1354 | 1355 | // Apply same filtering 1356 | // Enhancement always targets user messages only 1357 | if (message.role === 'user') { 1358 | // Extract text from message 1359 | const messageText = this._extractMessageText(message); 1360 | 1361 | // Skip system-reminders 1362 | if (messageText.trim().startsWith('')) { 1363 | this.log(` [SKIPPED] Message ${i} is system-reminder, continuing search...`); 1364 | continue; 1365 | } 1366 | 1367 | // Create simple hash of message for identification 1368 | let messageHash = 0; 1369 | for (let j = 0; j < messageText.length; j++) { 1370 | const char = messageText.charCodeAt(j); 1371 | messageHash = ((messageHash << 5) - messageHash) + char; 1372 | messageHash = messageHash & messageHash; // Convert to 32bit integer 1373 | } 1374 | const hashHex = (messageHash >>> 0).toString(16).toUpperCase().padStart(8, '0'); 1375 | 1376 | this.log(` [LAST USER MESSAGE] Message ${i} - Hash: ${hashHex}`); 1377 | this.log(` "${messageText.substring(0, 100).replace(/\n/g, '↕')}${messageText.length > 100 ? '...' : ''}"`); 1378 | 1379 | // Modify the prompt of the last valid message 1380 | // Safety check: Ensure messages array exists before cloning 1381 | if (!request.messages || !Array.isArray(request.messages)) { 1382 | this.log(' [WARNING] request.messages invalid, skipping enhancement'); 1383 | break; 1384 | } 1385 | 1386 | // If we already modified messages to remove tags, use that copy 1387 | if (!modifiedRequest.messages) { 1388 | modifiedRequest.messages = [...request.messages]; 1389 | } 1390 | const modifiedMessage = { ...modifiedRequest.messages[i] }; 1391 | 1392 | if (typeof modifiedMessage.content === 'string') { 1393 | modifiedMessage.content = this.modifyPromptForReasoning(modifiedMessage.content, ultrathinkDetected); 1394 | } else if (Array.isArray(modifiedMessage.content)) { 1395 | modifiedMessage.content = modifiedMessage.content.map(content => { 1396 | if (content.type === 'text' && content.text) { 1397 | return { ...content, text: this.modifyPromptForReasoning(content.text, ultrathinkDetected) }; 1398 | } 1399 | return content; 1400 | }); 1401 | } 1402 | 1403 | modifiedRequest.messages[i] = modifiedMessage; 1404 | this.log(' [COMPLETED] Reasoning instructions added to the last message prompt'); 1405 | 1406 | // Already modified the last valid message, exit loop 1407 | break; 1408 | } 1409 | } 1410 | } else { 1411 | // Log why NOT enhancing 1412 | if (!effectiveReasoning) { 1413 | this.log(' [SKIPPED] NOT enhancing prompt: reasoning disabled'); 1414 | } else if (!finalKeywordDetection) { 1415 | this.log(' [SKIPPED] NOT enhancing prompt: keywordDetection=false'); 1416 | } 1417 | } 1418 | } 1419 | 1420 | this.log('╚═══════════════════════════════════════════════════════════════════════════════════════════════════╝'); 1421 | 1422 | // ======================================== 1423 | // 4. OUTPUT: CCR → LLM Provider 1424 | // ======================================== 1425 | this.log(''); 1426 | this.log(''); 1427 | this.log('╔═══════════════════════════════════════════════════════════════════════════════════════════════════╗'); 1428 | this.log(` [STAGE 2/3] OUTPUT: transformRequestIn() → CCR → LLM Provider [Request #${currentRequestId}]`); 1429 | this.log(' OPTIMIZED request to be sent to provider'); 1430 | this.log('╚═══════════════════════════════════════════════════════════════════════════════════════════════════╝'); 1431 | this.log(''); 1432 | this.log(' [OUTPUT] Body to be sent to provider:'); 1433 | this.log(` model: "${modifiedRequest.model}"`); 1434 | this.log(` max_tokens: ${modifiedRequest.max_tokens}`); 1435 | this.log(` temperature: ${modifiedRequest.temperature || 'undefined'}`); 1436 | this.log(` top_p: ${modifiedRequest.top_p || 'undefined'}`); 1437 | this.log(` do_sample: true`); 1438 | this.log(` stream: ${modifiedRequest.stream}`); 1439 | 1440 | // Show message preview (roles and content length) 1441 | if (modifiedRequest.messages && modifiedRequest.messages.length > 0) { 1442 | this.log(` messages: ${modifiedRequest.messages.length} messages`); 1443 | modifiedRequest.messages.forEach((msg, idx) => { 1444 | const role = msg.role || 'unknown'; 1445 | 1446 | // Extract real text from content (handle string or array) 1447 | let textContent = ''; 1448 | if (typeof msg.content === 'string') { 1449 | textContent = msg.content; 1450 | } else if (Array.isArray(msg.content)) { 1451 | textContent = msg.content 1452 | .filter(item => item.type === 'text') 1453 | .map(item => item.text) 1454 | .join(' '); 1455 | } else { 1456 | textContent = JSON.stringify(msg.content || ''); 1457 | } 1458 | 1459 | const contentLength = textContent.length; 1460 | const preview = textContent.substring(0, 50).replace(/\n/g, ' '); 1461 | this.log(` [${idx}] ${role}: ${contentLength} chars - "${preview}${contentLength > 50 ? '...' : ''}"`); 1462 | }); 1463 | } else { 1464 | this.log(` messages: undefined`); 1465 | } 1466 | 1467 | // Show tools with their names (same as INPUT) 1468 | if (modifiedRequest.tools) { 1469 | this.log(` tools: ${modifiedRequest.tools.length} tools`); 1470 | const toolNames = modifiedRequest.tools.map((t, idx) => { 1471 | if (t.function?.name) return t.function.name; 1472 | if (t.name) return t.name; 1473 | return `tool_${idx}`; 1474 | }); 1475 | this.log(` └─ [${toolNames.slice(0, 10).join(', ')}${modifiedRequest.tools.length > 10 ? `, ... +${modifiedRequest.tools.length - 10} more` : ''}]`); 1476 | } else { 1477 | this.log(` tools: undefined`); 1478 | } 1479 | 1480 | this.log(` tool_choice: ${modifiedRequest.tool_choice || 'undefined'}`); 1481 | this.log(` thinking: ${this.safeJSON(modifiedRequest.thinking, 3, ' ') || 'undefined'}`); 1482 | 1483 | // Extra properties (show any other properties that might have been passed through or added) 1484 | const knownOutputProperties = [ 1485 | 'model', 'max_tokens', 'temperature', 'top_p', 'do_sample', 1486 | 'thinking', 'stream', 'messages', 1487 | 'tools', 'tool_choice' 1488 | ]; 1489 | const unknownOutputProperties = this.safeKeys(modifiedRequest).filter(k => !knownOutputProperties.includes(k)); 1490 | if (unknownOutputProperties.length > 0) { 1491 | this.log(` [EXTRAS]: ${unknownOutputProperties.join(', ')}`); 1492 | // Show values of extra properties 1493 | unknownOutputProperties.forEach(key => { 1494 | const value = modifiedRequest[key]; 1495 | if (value !== undefined) { 1496 | if (typeof value === 'object') { 1497 | this.log(` └─ ${key}: ${this.safeJSON(value, 2, ' ')}`); 1498 | } else { 1499 | this.log(` └─ ${key}: ${value}`); 1500 | } 1501 | } 1502 | }); 1503 | } 1504 | 1505 | this.log('╚═══════════════════════════════════════════════════════════════════════════════════════════════════╝'); 1506 | this.log(''); 1507 | 1508 | // Flush logs before returning (ensure they're written) 1509 | this.flushLogs(); 1510 | 1511 | return modifiedRequest; 1512 | } 1513 | 1514 | /** 1515 | * Transforms response before sending to Claude Code. 1516 | * 1517 | * @param {Response} response - Response processed by CCR 1518 | * @returns {Promise} Unmodified response 1519 | */ 1520 | async transformResponseOut (response) { 1521 | // Get Request ID first (before logging) to show in header 1522 | const requestId = this.requestCounter; // Use current counter as this Response belongs to last Request 1523 | 1524 | this.log(''); 1525 | this.log('╔═══════════════════════════════════════════════════════════════════════════════════════════════════╗'); 1526 | this.log(` [STAGE 3/3] LLM Provider → CCR → transformResponseOut() [Request #${requestId}]`); 1527 | this.log(' Response RECEIVED from provider, BEFORE sending to Claude Code'); 1528 | this.log('╚═══════════════════════════════════════════════════════════════════════════════════════════════════╝'); 1529 | this.log(''); 1530 | 1531 | // Detect response type and avoid duplicate processing 1532 | if (response?.constructor?.name === 'Response' && !this.processedResponses.has(response)) { 1533 | 1534 | // Generate unique ID for this Response object using counter + timestamp 1535 | this.responseIdCounter++; 1536 | 1537 | // Auto-reset counter when approaching maximum safe integer 1538 | if (this.responseIdCounter >= Number.MAX_SAFE_INTEGER - 1000) { 1539 | this.responseIdCounter = 1; 1540 | } 1541 | 1542 | const responseId = `${Date.now()}-${this.responseIdCounter}`; 1543 | 1544 | this.log(` [INFO] Response for Request #${requestId} | Response Object ID: ${responseId}`); 1545 | this.log(''); 1546 | 1547 | // Mark as processed to avoid duplicate reads 1548 | this.processedResponses.add(response); 1549 | 1550 | // It's a Response object - show info and read chunks 1551 | this.log(` [RESPONSE OBJECT DETECTED]`); 1552 | this.log(` Response.ok: ${response.ok}`); 1553 | this.log(` Response.status: ${response.status} ${response.statusText}`); 1554 | this.log(` Response.url: ${response.url}`); 1555 | this.log(` Response.bodyUsed: ${response.bodyUsed}`); 1556 | 1557 | // Show important headers 1558 | try { 1559 | const contentType = response.headers?.get('content-type'); 1560 | if (contentType) this.log(` Content-Type: ${contentType}`); 1561 | } catch (e) { 1562 | this.log(` Headers: Not available`); 1563 | } 1564 | 1565 | this.log(``); 1566 | this.log(` NOTE: This is the original Response BEFORE CCR parsing.`); 1567 | this.log(` CCR will read the stream and convert it to Anthropic format for Claude Code.`); 1568 | 1569 | // READ REAL CHUNKS FROM STREAM 1570 | this.log(''); 1571 | this.log('╔═══════════════════════════════════════════════════════════════════════════════════════════════════╗'); 1572 | this.log(' [STREAMING] Reading first chunks from Response'); 1573 | this.log(' RAW stream content BEFORE CCR parses it'); 1574 | this.log('╚═══════════════════════════════════════════════════════════════════════════════════════════════════╝'); 1575 | this.log(''); 1576 | 1577 | // Read chunks in BACKGROUND 1578 | (async () => { 1579 | try { 1580 | // Clone Response to not consume original that CCR needs 1581 | const cloned = response.clone(); 1582 | const reader = cloned.body.getReader(); 1583 | const decoder = new TextDecoder(); 1584 | 1585 | let chunksRead = 0; 1586 | const maxChunks = 20; 1587 | const chunksToShow = []; // Buffer to accumulate chunks before showing 1588 | 1589 | try { 1590 | // Read chunks asynchronously 1591 | while (chunksRead < maxChunks) { 1592 | const { done, value } = await reader.read(); 1593 | 1594 | if (done) { 1595 | chunksToShow.push(` [STREAM] Ended after ${chunksRead} chunks`); 1596 | break; 1597 | } 1598 | 1599 | chunksRead++; 1600 | const text = decoder.decode(value, { stream: true }); 1601 | 1602 | // Detect if contains reasoning_content or content 1603 | const hasReasoning = text.includes('"reasoning_content"'); 1604 | const hasContent = text.includes('"content"') && !hasReasoning; 1605 | const type = hasReasoning ? '[THINKING]' : hasContent ? '[CONTENT]' : '[DATA]'; 1606 | 1607 | // Extract useful properties from chunk (delta, role, etc.) 1608 | let usefulInfo = ''; 1609 | try { 1610 | // Try to parse chunk JSON to extract useful info 1611 | // SSE chunks come as: data: {...JSON...} 1612 | const textLines = text.split('\n'); 1613 | for (const line of textLines) { 1614 | if (line.startsWith('data: ')) { 1615 | const jsonStr = line.substring(6).trim(); // Remove "data: " and trim 1616 | if (!jsonStr || jsonStr === '[DONE]') continue; // Skip empty or [DONE] 1617 | 1618 | // Validate JSON string before parsing 1619 | try { 1620 | const chunkData = JSON.parse(jsonStr); 1621 | 1622 | // Extract info from delta (most important) 1623 | if (chunkData.choices && chunkData.choices[0] && chunkData.choices[0].delta) { 1624 | const delta = chunkData.choices[0].delta; 1625 | const properties = []; 1626 | 1627 | if (delta.role) properties.push(`role:"${delta.role}"`); 1628 | if (delta.content !== undefined) { 1629 | const contentStr = String(delta.content); 1630 | const contentPreview = contentStr.substring(0, 30).replace(/\n/g, '↵'); 1631 | properties.push(`content:"${contentPreview}${contentStr.length > 30 ? '...' : ''}"`); 1632 | } 1633 | if (delta.reasoning_content !== undefined) { 1634 | const reasoningStr = String(delta.reasoning_content); 1635 | const reasoningPreview = reasoningStr.substring(0, 30).replace(/\n/g, '↵'); 1636 | properties.push(`reasoning_content:"${reasoningPreview}${reasoningStr.length > 30 ? '...' : ''}"`); 1637 | } 1638 | if (delta.finish_reason) properties.push(`finish_reason:"${delta.finish_reason}"`); 1639 | 1640 | if (properties.length > 0) { 1641 | usefulInfo = ` → {${properties.join(', ')}}`; 1642 | } 1643 | } 1644 | } catch (parseError) { 1645 | // Skip invalid JSON chunks 1646 | continue; 1647 | } 1648 | break; // Only process first data: line 1649 | } 1650 | } 1651 | } catch (e) { 1652 | // If parse fails, don't show extra info 1653 | usefulInfo = ''; 1654 | } 1655 | 1656 | chunksToShow.push(` [CHUNK ${chunksRead}] ${value.byteLength} bytes ${type}${usefulInfo}`); 1657 | } 1658 | 1659 | if (chunksRead >= maxChunks) { 1660 | chunksToShow.push(` [STREAM] Limit of ${maxChunks} chunks reached (more data exists)`); 1661 | } 1662 | 1663 | chunksToShow.push(``); 1664 | chunksToShow.push(` [SUCCESS] Reading completed - Original Response was NOT consumed`); 1665 | 1666 | // Show all accumulated chunks at once (atomic) 1667 | chunksToShow.forEach(line => this.log(line)); 1668 | 1669 | // Close the streaming block 1670 | this.log('╚═══════════════════════════════════════════════════════════════════════════════════════════════════╝'); 1671 | this.log(''); 1672 | } finally { 1673 | // Always cancel reader, even if error 1674 | try { 1675 | await reader.cancel(); 1676 | } catch (e) { 1677 | // Ignore cancellation error 1678 | } 1679 | } 1680 | } catch (err) { 1681 | this.log(` [ERROR] Reading stream: ${err.message}`); 1682 | this.log('╚═══════════════════════════════════════════════════════════════════════════════════════════════════╝'); 1683 | this.log(''); 1684 | } 1685 | 1686 | this.flushLogs(); 1687 | })().catch(() => { /* Ignore stream cancellation error */ }); // Execute in background, catch to silence unhandled rejection warnings 1688 | 1689 | // Return Response immediately (don't wait for chunk reading) 1690 | return response; 1691 | } 1692 | 1693 | // CCR calls this method multiple times: first with complete Response object, 1694 | // then with each parsed chunk individually. Chunks were already shown 1695 | // in first call (Response object), so here we just return the chunk 1696 | // without additional logging to avoid information duplication. 1697 | return response; 1698 | } 1699 | } 1700 | 1701 | // Export class for CCR 1702 | module.exports = ZaiTransformer; 1703 | --------------------------------------------------------------------------------