################################################################################ # 闪电API - OpenCode 一键安装脚本 (Windows PowerShell) # # 功能: 安装 OpenCode + 配置 闪电API (Claude + GPT 两个供应商) # 官网: flashapi.top # 模型调用: ai.flashapi.top (国内直连) / cdn.flashapi.top (海外CDN) # # 用法 (PowerShell): irm https://sh.flashapi.top/opencode.ps1 | iex # # 兼容 Windows PowerShell 5.1 与 PowerShell 7+;不需要管理员权限,所有操作在用户目录下完成 ################################################################################ & { $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch {} try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {} ############################################################################## # 常量定义 ############################################################################## $OnWindows = ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) $UserHome = $env:USERPROFILE if (-not $UserHome) { $UserHome = $HOME } $NpmMirror = 'https://registry.npmmirror.com' $OpencodePackage = 'opencode-ai' $ProviderClaude = 'flashapi-claude' $ProviderGpt = 'flashapi-gpt' $DefaultClaudeModel = 'claude-opus-5-5' $DefaultGptModel = 'gpt-6.1-sol' $DefaultEffort = 'medium' $BigClaude = @('claude-opus-5-5', 'claude-sonnet-5-5', 'claude-fable-5-1', 'claude-fable-5', 'claude-opus-5', 'claude-sonnet-5', 'claude-opus-4-8') $BigGpt = @('gpt-6.1-sol', 'gpt-6-sol', 'gpt-6-astra', 'gpt-6-luna') # 可变状态放在哈希表里 (子函数可直接修改,且不污染用户会话) $S = @{ ConfigDir = '' ConfigFile = '' } $Line = '───────────────────────────────────────────────────────' ############################################################################## # 工具函数 ############################################################################## # 输出带颜色的一行: Say '文本' '颜色' '文本' '颜色' ... (颜色为空 = 默认色) function Write-Parts { param([object[]]$Parts, [bool]$NoNewline) if ($null -ne $Parts) { for ($i = 0; $i -lt $Parts.Count; $i += 2) { $text = [string]$Parts[$i] $color = '' if ($i + 1 -lt $Parts.Count) { $color = [string]$Parts[$i + 1] } if ($color) { Write-Host $text -NoNewline -ForegroundColor $color } else { Write-Host $text -NoNewline } } } if (-not $NoNewline) { Write-Host '' } } function Say { Write-Parts -Parts $args -NoNewline $false } function SayN { Write-Parts -Parts $args -NoNewline $true } function Read-Line { # 读取一行输入 (与 bash 的 read -r 一样去掉首尾空白);输入流结束时返回 $null $v = Read-Host if ($null -eq $v) { return $null } return ([string]$v).Trim() } function Read-YesNo { param([string]$Prompt) SayN " $Prompt" 'Yellow' $a = Read-Line return ($a -eq 'y' -or $a -eq 'Y') } function Print-Banner { Say '' Say ' ' '' ' ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡' 'Yellow' Say ' ' '' ' ██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗' 'Yellow' Say ' ' '' ' ██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝' 'Yellow' Say ' ' '' ' ██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ██║ ██║██║ ██║█████╗' 'Yellow' Say ' ' '' ' ██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██║ ██║██║ ██║██╔══╝' 'Yellow' Say ' ' '' ' ╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗╚██████╔╝██████╔╝███████╗' 'Yellow' Say ' ' '' ' ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝' 'Yellow' Say ' ' '' ' ⚡ 闪电API × OpenCode ⚡' 'Blue' Say ' ' '' ' ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡' 'Yellow' Say '' Say " $Line" 'DarkGray' Say ' ' '' '⚡ 闪电API' 'White' ' ' '' '|' 'DarkGray' ' ' '' '官网' 'Cyan' ' ' '' 'flashapi.top' 'Yellow' Say ' ' '' 'OpenCode 一键配置' 'White' ' ' '' '|' 'DarkGray' ' ' '' '让顶尖模型触手可及' 'Cyan' Say " $Line" 'DarkGray' Say ' ' '' '国内直连:' 'DarkGray' ' ' '' 'ai.flashapi.top' 'Green' ' ' '' '海外CDN:' 'DarkGray' ' ' '' 'cdn.flashapi.top' 'Green' Say " $Line" 'DarkGray' Say '' } function Print-Status { param([string]$Type, [string]$Message) switch ($Type) { 'ok' { Say ' ' '' '[⚡]' 'Green' " $Message" '' } 'info' { Say ' ' '' '[*]' 'Cyan' " $Message" '' } 'warn' { Say ' ' '' '[!]' 'Yellow' " $Message" '' } 'error' { Say ' ' '' '[x]' 'Red' " $Message" '' } default { Say " $Message" } } } # 读取密码输入 (星号遮盖) function Read-Secret { param([string]$Prompt) SayN $Prompt 'White' $sec = Read-Host -AsSecureString if ($null -eq $sec -or $sec.Length -eq 0) { return '' } $plain = '' $bstr = [IntPtr]::Zero try { $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec) $plain = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } catch { $plain = (New-Object System.Management.Automation.PSCredential('x', $sec)).GetNetworkCredential().Password } finally { if ($bstr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } if ($null -eq $plain) { return '' } return $plain.Trim() } function Mask-Key { param([string]$Value) if (-not $Value) { return '' } if ($Value.Length -le 12) { return $Value } return $Value.Substring(0, 8) + '...' + $Value.Substring($Value.Length - 4) } function Backup-IfExists { param([string]$File) if (Test-Path -LiteralPath $File -PathType Leaf) { $backup = $File + '.bak.' + (Get-Date -Format 'yyyyMMddHHmmss') Copy-Item -LiteralPath $File -Destination $backup -Force Print-Status 'info' "已备份现有文件: $backup" } } function Ensure-Dir { param([string]$Dir) if (-not (Test-Path -LiteralPath $Dir -PathType Container)) { New-Item -ItemType Directory -Path $Dir -Force | Out-Null } } # 写文件: UTF-8 无 BOM,LF 换行 function Write-Utf8File { param([string]$Path, [string]$Text) [IO.File]::WriteAllText($Path, $Text, (New-Object Text.UTF8Encoding $false)) } # 在 PATH 中查找可执行文件 (优先 .exe/.cmd,避开受执行策略限制的 .ps1 shim) function Find-Exe { param([string[]]$Names) foreach ($n in $Names) { $c = Get-Command $n -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -ne $c) { return $c.Path } } return $null } function Get-ExeVersion { param([string]$Exe) $ErrorActionPreference = 'Continue' $out = $null try { $out = & $Exe --version 2>$null | Select-Object -First 1 } catch { $out = $null } if ($out) { return ([string]$out).Trim() } return 'unknown' } function Add-SessionPath { param([string]$Dir) if (-not $Dir) { return } if (-not (Test-Path -LiteralPath $Dir -PathType Container)) { return } $sep = [IO.Path]::PathSeparator $parts = @($env:PATH -split [regex]::Escape([string]$sep)) if ($parts -notcontains $Dir) { $env:PATH = $Dir + $sep + $env:PATH } } function Expand-Tilde { param([string]$P) if ($P -eq '~') { return $UserHome } if ($P.StartsWith('~/') -or $P.StartsWith('~\')) { return (Join-Path $UserHome $P.Substring(2)) } return $P } # OpenCode 配置文件位置 (与 OpenCode 自身的查找逻辑一致): # 1. $env:OPENCODE_CONFIG 2. $env:OPENCODE_CONFIG_DIR # 3. $env:XDG_CONFIG_HOME\opencode,默认 %USERPROFILE%\.config\opencode (Windows 也是这个目录) # 目录内优先 opencode.jsonc (已存在时),否则 opencode.json function Resolve-ConfigPath { if ($env:OPENCODE_CONFIG) { $S.ConfigFile = Expand-Tilde $env:OPENCODE_CONFIG $S.ConfigDir = Split-Path -Parent $S.ConfigFile return } if ($env:OPENCODE_CONFIG_DIR) { $S.ConfigDir = Expand-Tilde $env:OPENCODE_CONFIG_DIR } else { $base = $env:XDG_CONFIG_HOME if (-not $base) { $base = Join-Path $UserHome '.config' } $S.ConfigDir = Join-Path $base 'opencode' } $jsonc = Join-Path $S.ConfigDir 'opencode.jsonc' if (Test-Path -LiteralPath $jsonc -PathType Leaf) { $S.ConfigFile = $jsonc } else { $S.ConfigFile = Join-Path $S.ConfigDir 'opencode.json' } } ############################################################################## # JSON 工具 (PSCustomObject 读写;输出格式与 node 的 JSON.stringify(x, null, 2) 一致) ############################################################################## $JsonDateKind = $false try { $JsonDateKind = (Get-Command ConvertFrom-Json).Parameters.ContainsKey('DateKind') } catch {} function Test-JsonObject { param($Value) return ($null -ne $Value -and $Value.GetType().FullName -eq 'System.Management.Automation.PSCustomObject') } # JSONC 容错: 去掉 // 与 /* */ 注释、尾逗号 (字符串内容不受影响) # 返回 @{ Text = ...; HadComments = $true/$false } function Remove-JsoncSyntax { param([string]$Text) $sb = New-Object System.Text.StringBuilder $had = $false $n = $Text.Length $i = 0 while ($i -lt $n) { $c = $Text[$i] if ($c -eq '"') { $j = $i + 1 while ($j -lt $n -and $Text[$j] -ne '"') { if ($Text[$j] -eq '\') { $j += 2 } else { $j += 1 } } $end = [Math]::Min($j, $n - 1) [void]$sb.Append($Text.Substring($i, $end - $i + 1)) $i = $j + 1 } elseif ($c -eq '/' -and $i + 1 -lt $n -and $Text[$i + 1] -eq '/') { $had = $true while ($i -lt $n -and $Text[$i] -ne "`n") { $i++ } } elseif ($c -eq '/' -and $i + 1 -lt $n -and $Text[$i + 1] -eq '*') { $had = $true $e = $Text.IndexOf('*/', $i + 2) if ($e -lt 0) { $i = $n } else { $i = $e + 2 } [void]$sb.Append(' ') } else { [void]$sb.Append($c) $i++ } } $out = $sb.ToString() # 尾逗号 $sb2 = New-Object System.Text.StringBuilder $m = $out.Length $i = 0 while ($i -lt $m) { $c = $out[$i] if ($c -eq '"') { $j = $i + 1 while ($j -lt $m -and $out[$j] -ne '"') { if ($out[$j] -eq '\') { $j += 2 } else { $j += 1 } } $end = [Math]::Min($j, $m - 1) [void]$sb2.Append($out.Substring($i, $end - $i + 1)) $i = $j + 1 } elseif ($c -eq ',') { $k = $i + 1 while ($k -lt $m -and [char]::IsWhiteSpace($out[$k])) { $k++ } if ($k -lt $m -and ($out[$k] -eq '}' -or $out[$k] -eq ']')) { $i++; continue } [void]$sb2.Append($c) $i++ } else { [void]$sb2.Append($c) $i++ } } return @{ Text = $sb2.ToString(); HadComments = $had } } function ConvertFrom-JsonText { param([string]$Text) $opts = @{} if ($JsonDateKind) { $opts['DateKind'] = 'String' } return (ConvertFrom-Json -InputObject $Text @opts) } # 读取 OpenCode 配置 (JSON / JSONC)。 # 返回 @{ Data = PSCustomObject; HadComments = bool };文件不存在/为空时 Data 为新对象;解析失败抛异常 function Read-ConfigFile { param([string]$Path) $result = @{ Data = (New-Object -TypeName PSObject); HadComments = $false } if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $result } $text = [IO.File]::ReadAllText($Path) if ($text.Length -gt 0 -and [int]$text[0] -eq 0xFEFF) { $text = $text.Substring(1) } if (-not $text.Trim()) { return $result } # 统一先做 JSONC 预处理 (PowerShell 7 的 ConvertFrom-Json 虽能容忍注释,但写回时注释同样会丢失) $st = Remove-JsoncSyntax $text $result.HadComments = $st.HadComments $obj = $null try { $obj = ConvertFrom-JsonText $st.Text } catch { throw "$Path 不是合法的 JSON/JSONC: $($_.Exception.Message)" } if (-not (Test-JsonObject $obj)) { throw "$Path 根节点不是 JSON 对象" } $result.Data = $obj return $result } function New-JsonObject { return (New-Object -TypeName PSObject) } function Get-Prop { param($Obj, [string]$Name) if ($null -eq $Obj) { return $null } if (-not (Test-JsonObject $Obj)) { return $null } $p = $Obj.PSObject.Properties[$Name] if ($null -eq $p) { return $null } return $p.Value } function Set-Prop { param($Obj, [string]$Name, $Value) $p = $Obj.PSObject.Properties[$Name] if ($null -ne $p) { $p.Value = $Value } else { Add-Member -InputObject $Obj -MemberType NoteProperty -Name $Name -Value $Value } } function Remove-Prop { param($Obj, [string]$Name) if ($null -ne $Obj.PSObject.Properties[$Name]) { $Obj.PSObject.Properties.Remove($Name) } } function ConvertTo-JsonString { param([string]$s) $sb = New-Object System.Text.StringBuilder [void]$sb.Append('"') foreach ($ch in $s.ToCharArray()) { $code = [int]$ch switch ($code) { 34 { [void]$sb.Append('\"') } 92 { [void]$sb.Append('\\') } 8 { [void]$sb.Append('\b') } 12 { [void]$sb.Append('\f') } 10 { [void]$sb.Append('\n') } 13 { [void]$sb.Append('\r') } 9 { [void]$sb.Append('\t') } default { if ($code -lt 32) { [void]$sb.Append('\u' + $code.ToString('x4')) } else { [void]$sb.Append($ch) } } } } [void]$sb.Append('"') return $sb.ToString() } function ConvertTo-JsonText { param($Value, [int]$Level = 0) $inv = [Globalization.CultureInfo]::InvariantCulture if ($null -eq $Value) { return 'null' } if ($Value -is [string]) { return (ConvertTo-JsonString $Value) } if ($Value -is [char]) { return (ConvertTo-JsonString ([string]$Value)) } if ($Value -is [bool]) { if ($Value) { return 'true' } else { return 'false' } } if ($Value -is [DateTime]) { return (ConvertTo-JsonString ($Value.ToString('yyyy-MM-ddTHH:mm:ss.FFFFFFFK', $inv))) } if ($Value -is [DateTimeOffset]) { return (ConvertTo-JsonString ($Value.ToString('yyyy-MM-ddTHH:mm:ss.FFFFFFFzzz', $inv))) } if ($Value -is [double] -or $Value -is [float]) { $d = [double]$Value if ([double]::IsNaN($d) -or [double]::IsInfinity($d)) { return 'null' } return $d.ToString('R', $inv) } if ($Value -is [decimal] -or $Value -is [int] -or $Value -is [long] -or $Value -is [int16] -or $Value -is [byte] -or $Value -is [sbyte] -or $Value -is [uint16] -or $Value -is [uint32] -or $Value -is [uint64]) { return ([IFormattable]$Value).ToString($null, $inv) } if ($Value.GetType().FullName -eq 'System.Numerics.BigInteger') { return $Value.ToString() } $pad = ' ' * ($Level + 1) $endPad = ' ' * $Level if ($Value -is [Collections.IDictionary]) { $items = New-Object System.Collections.Generic.List[string] foreach ($k in $Value.Keys) { $items.Add($pad + (ConvertTo-JsonString ([string]$k)) + ': ' + (ConvertTo-JsonText $Value[$k] ($Level + 1))) } if ($items.Count -eq 0) { return '{}' } return "{`n" + ($items -join ",`n") + "`n" + $endPad + '}' } if (Test-JsonObject $Value) { $items = New-Object System.Collections.Generic.List[string] foreach ($p in $Value.PSObject.Properties) { $items.Add($pad + (ConvertTo-JsonString $p.Name) + ': ' + (ConvertTo-JsonText $p.Value ($Level + 1))) } if ($items.Count -eq 0) { return '{}' } return "{`n" + ($items -join ",`n") + "`n" + $endPad + '}' } if ($Value -is [Collections.IEnumerable]) { $items = New-Object System.Collections.Generic.List[string] foreach ($e in $Value) { $items.Add($pad + (ConvertTo-JsonText $e ($Level + 1))) } if ($items.Count -eq 0) { return '[]' } return "[`n" + ($items -join ",`n") + "`n" + $endPad + ']' } return (ConvertTo-JsonString ([string]$Value)) } ############################################################################## # 功能 1: 安装 OpenCode ############################################################################## function Get-OpencodeExe { $exe = Find-Exe @('opencode.exe', 'opencode.cmd', 'opencode') if ($exe) { return $exe } foreach ($c in @( (Join-Path (Join-Path (Join-Path $UserHome '.opencode') 'bin') 'opencode.exe'), (Join-Path (Join-Path (Join-Path $UserHome '.opencode') 'bin') 'opencode'))) { if (Test-Path -LiteralPath $c -PathType Leaf) { return $c } } return $null } # 已安装返回版本号,未安装返回 $null function Check-OpencodeInstalled { $exe = Get-OpencodeExe if (-not $exe) { return $null } return (Get-ExeVersion $exe) } # npm 12+ 默认不执行全局包的 install 脚本,而 opencode-ai 需要 postinstall 来放置平台二进制 function Get-NpmExtraArgs { param([string]$Npm) $v = Get-ExeVersion $Npm $major = 0 if ($v -match '^(\d+)\.') { $major = [int]$Matches[1] } if ($major -ge 11) { return @("--allow-scripts=$OpencodePackage") } return @() } function Invoke-NpmInstall { param([string]$Npm, [string]$Registry) $ErrorActionPreference = 'Continue' $npmArgs = @('install', '-g', "$OpencodePackage@latest") if ($Registry) { $npmArgs += "--registry=$Registry" } $npmArgs += @(Get-NpmExtraArgs $Npm) try { & $Npm @npmArgs | Out-Host return ($LASTEXITCODE -eq 0) } catch { return $false } } function Install-WithMirror { param([string]$Npm) Print-Status 'info' '切换国内镜像 (npmmirror.com) 安装 ...' return (Invoke-NpmInstall $Npm $NpmMirror) } function Add-NpmBinToPath { param([string]$Npm) $ErrorActionPreference = 'Continue' $prefix = $null try { $prefix = (& $Npm prefix -g 2>$null | Select-Object -First 1) } catch {} if (-not $prefix) { return } $prefix = ([string]$prefix).Trim() if ($OnWindows) { Add-SessionPath $prefix } else { Add-SessionPath (Join-Path $prefix 'bin') } } # 兜底: 若 postinstall 仍未执行 (opencode --version 报错),手动执行一次 function Repair-NpmPostinstall { param([string]$Npm) $ErrorActionPreference = 'Continue' $exe = Get-OpencodeExe if ($exe) { try { & $exe --version 2>$null | Out-Null } catch {} if ($LASTEXITCODE -eq 0) { return } } $root = $null try { $root = (& $Npm root -g 2>$null | Select-Object -First 1) } catch {} if (-not $root) { return } $pkgDir = Join-Path ([string]$root).Trim() $OpencodePackage $script = Join-Path $pkgDir 'postinstall.mjs' if (-not (Test-Path -LiteralPath $script -PathType Leaf)) { return } $node = Find-Exe @('node.exe', 'node') if (-not $node) { return } Print-Status 'info' "执行 $OpencodePackage 的 postinstall 脚本 ..." Push-Location $pkgDir try { & $node $script | Out-Host } catch {} finally { Pop-Location } } function Install-WithScoop { $scoop = Find-Exe @('scoop.cmd', 'scoop') if (-not $scoop) { return $false } Print-Status 'info' '使用 scoop 安装 opencode ...' $ErrorActionPreference = 'Continue' try { & $scoop install opencode | Out-Host return ($LASTEXITCODE -eq 0) } catch { return $false } } function Install-Opencode { Say '' Print-Status 'info' '⚡ 正在安装 OpenCode ...' Say '' if ($OnWindows) { Print-Status 'info' '检测到 Windows (PowerShell) 环境' } $ver = Check-OpencodeInstalled if ($null -ne $ver) { Print-Status 'ok' "OpenCode 已安装 ($ver)" Say '' if (-not (Read-YesNo '是否重新安装/升级到最新版? (y/N): ')) { Print-Status 'info' '跳过安装'; return $true } } $installed = $false $npm = Find-Exe @('npm.cmd', 'npm') if ($npm) { $npmVer = Get-ExeVersion $npm Print-Status 'ok' "npm 已就绪 ($npmVer)" Print-Status 'info' "使用 npm 安装 $OpencodePackage ..." Say '' if (Invoke-NpmInstall $npm '') { $installed = $true } else { Print-Status 'warn' '默认源安装失败,可能是网络问题' if (Install-WithMirror $npm) { $installed = $true } } if ($installed) { Add-NpmBinToPath $npm Repair-NpmPostinstall $npm } } else { Print-Status 'warn' '未找到 npm' if (Install-WithScoop) { $installed = $true } } if (-not $installed) { Print-Status 'error' '所有安装方式均失败' Say '' Say ' ' '' '安装方式:' 'White' Say ' ' '' 'Node.js:' 'Cyan' " 先安装 Node.js (https://nodejs.org/ 或 winget install --id OpenJS.NodeJS.LTS -e),再运行 npm i -g $OpencodePackage@latest --registry=$NpmMirror" '' Say ' ' '' 'Scoop:' 'Cyan' ' scoop install opencode' '' Say ' ' '' 'Chocolatey:' 'Cyan' ' choco install opencode' '' Say ' ' '' '手动下载:' 'Cyan' ' https://github.com/anomalyco/opencode/releases' '' return $false } Say '' $ver = Check-OpencodeInstalled if ($null -ne $ver) { Print-Status 'ok' "⚡ OpenCode 安装成功 ($ver)" } else { Print-Status 'warn' '安装已完成,但未在 PATH 中找到 opencode' Print-Status 'info' '请重新打开终端后再试' } if ($OnWindows) { Print-Status 'info' 'OpenCode 官方推荐在 WSL 中使用以获得最佳体验 (可在 WSL 中运行本站的 opencode.sh)' } return $true } ############################################################################## # 功能 2: 配置 API ############################################################################## # 返回 @{ Code = HTTP 状态码 (网络错误为 0); Body = 响应文本 } function Invoke-HttpGet { param([string]$Uri, [hashtable]$Headers) try { $r = Invoke-WebRequest -Uri $Uri -Headers $Headers -Method Get -UseBasicParsing -TimeoutSec 15 $body = $r.Content if ($body -is [byte[]]) { $body = [Text.Encoding]::UTF8.GetString($body) } return @{ Code = [int]$r.StatusCode; Body = [string]$body } } catch { $ex = $_.Exception $resp = $null if ($null -ne $ex -and $null -ne $ex.PSObject.Properties['Response']) { $resp = $ex.Response } if ($null -ne $resp) { $code = 0 try { $code = [int]$resp.StatusCode } catch { $code = 0 } return @{ Code = $code; Body = '' } } return @{ Code = 0; Body = '' } } } # 过滤某家族的模型 ID function Select-FamilyIds { param([string]$Family, [string]$Body) $ids = New-Object System.Collections.Generic.List[string] $j = $null try { $j = ConvertFrom-JsonText $Body } catch { return ,$ids.ToArray() } $list = $null if (Test-JsonObject $j) { $list = Get-Prop $j 'data' } elseif ($j -is [Collections.IEnumerable]) { $list = $j } if ($null -eq $list) { return ,$ids.ToArray() } foreach ($m in @($list)) { $id = [string](Get-Prop $m 'id') if (-not $id) { continue } if ($Family -eq 'claude') { if (-not $id.StartsWith('claude-')) { continue } } else { if (-not $id.StartsWith('gpt-')) { continue } if ($id.Contains('compact')) { continue } } if (-not $ids.Contains($id)) { $ids.Add($id) } } return ,$ids.ToArray() } # 验证 Key;返回 @{ Ok = 是否继续; Ids = 模型 ID 数组 } function Validate-Key { param([string]$Family, [string]$BaseUrl, [string]$ApiKey) $label = 'Claude' if ($Family -eq 'gpt') { $label = 'GPT' } Print-Status 'info' "正在验证 $label 分组 API Key ..." $r = Invoke-HttpGet "$BaseUrl/v1/models" @{ 'Authorization' = "Bearer $ApiKey" } $ids = @() if ($r.Code -eq 200) { $ids = Select-FamilyIds $Family $r.Body if ($ids.Count -gt 0) { Print-Status 'ok' "⚡ $label Key 验证通过,获取到 $($ids.Count) 个 $label 模型" } else { Print-Status 'warn' "Key 有效,但模型列表里没有 $label 模型 —— 这个 Key 可能不是 $label 分组" Print-Status 'info' '请到 flashapi.top 确认该令牌所属分组,将只写入默认模型' } } elseif ($r.Code -eq 401) { Print-Status 'error' "$label API Key 无效 (401 Unauthorized)" if (-not (Read-YesNo '验证未通过,是否仍要保存? (y/N): ')) { Print-Status 'info' '已取消配置'; return @{ Ok = $false; Ids = @() } } } elseif ($r.Code -eq 0) { Print-Status 'warn' "无法连接到 $BaseUrl (网络错误)" if (-not (Read-YesNo '验证未通过,是否仍要保存? (y/N): ')) { Print-Status 'info' '已取消配置'; return @{ Ok = $false; Ids = @() } } } else { Print-Status 'warn' "API 返回状态码: $($r.Code) (可能仍可用),将只写入默认模型" } return @{ Ok = $true; Ids = $ids } } function Get-PrettyName { param([string]$Id) $parts = $Id.Split('-') if ($parts[0] -eq 'gpt') { $words = New-Object System.Collections.Generic.List[string] $ver = '' if ($parts.Count -gt 1) { $ver = $parts[1] } $words.Add('GPT-' + $ver) for ($i = 2; $i -lt $parts.Count; $i++) { $p = $parts[$i] if ($p) { $words.Add($p.Substring(0, 1).ToUpperInvariant() + $p.Substring(1)) } } return ($words -join ' ').Trim() } $out = New-Object System.Collections.Generic.List[string] $nums = New-Object System.Collections.Generic.List[string] foreach ($p in $parts) { if ($p -match '^\d{1,2}$') { $nums.Add($p); continue } if ($nums.Count -gt 0) { $out.Add($nums -join '.'); $nums.Clear() } if ($p -match '^\d{8}$') { $out.Add("($p)") } elseif ($p) { $out.Add($p.Substring(0, 1).ToUpperInvariant() + $p.Substring(1)) } } if ($nums.Count -gt 0) { $out.Add($nums -join '.') } return ($out -join ' ') } # family 用于 OpenCode 自动挑选「小模型」(生成标题/摘要): 它会优先选 family=claude-haiku 的模型 function Get-ModelFamily { param([string]$Family, [string]$Id) if ($Family -eq 'gpt') { return 'gpt' } if ($Id -match '^claude-([a-z]+)') { return 'claude-' + $Matches[1] } return 'claude' } # Claude 默认推理参数 (与 OpenCode 1.18 provider/transform.ts 的 variants 结构一致) function Get-ClaudeOptions { param([string]$Id) if ($Id -notmatch '(?i)claude-(?:[a-z]+-)?(\d+)(?:[.-](\d{1,2}))?(?:[.@-]|$)') { return $null } $major = [int]$Matches[1] $minor = 0 if ($Matches.Count -gt 2 -and $Matches[2]) { $minor = [int]$Matches[2] } $modern = ($major -gt 4) -or ($major -eq 4 -and $minor -ge 7) $is46 = ($Id -match '(opus|sonnet)-4[.-]6') if ($modern) { return [pscustomobject]@{ thinking = [pscustomobject]@{ type = 'adaptive'; display = 'summarized' }; effort = $DefaultEffort } } if ($is46) { return [pscustomobject]@{ thinking = [pscustomobject]@{ type = 'adaptive' }; effort = $DefaultEffort } } return $null } function New-ModelEntry { param([string]$Family, [string]$Id) if ($Family -eq 'claude') { if ($BigClaude -contains $Id) { $ctx = 1000000; $out = 128000 } else { $ctx = 200000; $out = 64000 } } else { if ($BigGpt -contains $Id) { $ctx = 1050000 } else { $ctx = 272000 } $out = 128000 } $m = New-JsonObject Set-Prop $m 'name' (Get-PrettyName $Id) Set-Prop $m 'family' (Get-ModelFamily $Family $Id) Set-Prop $m 'attachment' $true Set-Prop $m 'reasoning' $true Set-Prop $m 'tool_call' $true Set-Prop $m 'limit' ([pscustomobject]@{ context = $ctx; output = $out }) Set-Prop $m 'modalities' ([pscustomobject]@{ input = @('text', 'image'); output = @('text') }) if ($Family -eq 'claude') { $o = Get-ClaudeOptions $Id if ($null -ne $o) { Set-Prop $m 'options' $o } } else { Set-Prop $m 'options' ([pscustomobject]@{ reasoningEffort = $DefaultEffort; reasoningSummary = 'auto'; include = @('reasoning.encrypted_content') }) } return $m } function New-ModelsObject { param([string]$Family, [string[]]$Ids, [string]$DefaultId) $all = New-Object System.Collections.Generic.List[string] $all.Add($DefaultId) foreach ($id in @($Ids)) { if ($id -and -not $all.Contains($id)) { $all.Add($id) } } $models = New-JsonObject foreach ($id in $all) { Set-Prop $models $id (New-ModelEntry $Family $id) } return $models } function New-ProviderEntry { param([string]$Npm, [string]$Name, [string]$BaseUrl, [string]$ApiKey, $Models) $p = New-JsonObject Set-Prop $p 'npm' $Npm Set-Prop $p 'name' $Name Set-Prop $p 'options' ([pscustomobject]@{ baseURL = $BaseUrl; apiKey = $ApiKey }) Set-Prop $p 'models' $Models return $p } # 合并写入 OpenCode 配置;返回 警告列表 (字符串数组) function Write-OpencodeConfig { param([string]$BaseUrl, [string]$ClaudeKey, [string[]]$ClaudeIds, [string]$GptKey, [string[]]$GptIds, [string]$DefaultModel) $file = $S.ConfigFile $read = Read-ConfigFile $file # 解析失败会抛异常 (此时尚未改动任何文件) $data = $read.Data $warnings = New-Object System.Collections.Generic.List[string] if ($read.HadComments) { $warnings.Add('COMMENTS') } Ensure-Dir $S.ConfigDir Backup-IfExists $file # $schema 放在最前面 if (-not ((Get-Prop $data '$schema') -is [string])) { $nd = New-JsonObject Set-Prop $nd '$schema' 'https://opencode.ai/config.json' foreach ($p in $data.PSObject.Properties) { if ($p.Name -ne '$schema') { Set-Prop $nd $p.Name $p.Value } } $data = $nd } $prov = Get-Prop $data 'provider' if (-not (Test-JsonObject $prov)) { if ($null -ne $data.PSObject.Properties['provider']) { $warnings.Add('PROVIDER_RESET') } $prov = New-JsonObject Set-Prop $data 'provider' $prov } $ours = New-Object System.Collections.Generic.List[string] if ($ClaudeKey) { $ours.Add($ProviderClaude) Set-Prop $prov $ProviderClaude (New-ProviderEntry '@ai-sdk/anthropic' '闪电API Claude' "$BaseUrl/v1" $ClaudeKey (New-ModelsObject 'claude' $ClaudeIds $DefaultClaudeModel)) } if ($GptKey) { $ours.Add($ProviderGpt) Set-Prop $prov $ProviderGpt (New-ProviderEntry '@ai-sdk/openai' '闪电API GPT' "$BaseUrl/v1" $GptKey (New-ModelsObject 'gpt' $GptIds $DefaultGptModel)) } # V2 原生格式 (providers.) 中的同名条目会与 V1 条目冲突,移除我们自己的 $v2 = Get-Prop $data 'providers' if (Test-JsonObject $v2) { foreach ($id in $ours) { if ($null -ne $v2.PSObject.Properties[$id]) { Remove-Prop $v2 $id; $warnings.Add('V2_REMOVED') } } } # 若用户用 enabled_providers 白名单 / disabled_providers 黑名单,确保我们的供应商可用 $enabled = Get-Prop $data 'enabled_providers' if ($null -ne $enabled -and ($enabled -is [Array])) { $list = New-Object System.Collections.ArrayList foreach ($e in $enabled) { [void]$list.Add($e) } foreach ($id in $ours) { if (-not $list.Contains($id)) { [void]$list.Add($id) } } Set-Prop $data 'enabled_providers' $list.ToArray() } $disabled = Get-Prop $data 'disabled_providers' if ($null -ne $disabled -and ($disabled -is [Array])) { $list = New-Object System.Collections.ArrayList foreach ($e in $disabled) { if (-not $ours.Contains([string]$e)) { [void]$list.Add($e) } } Set-Prop $data 'disabled_providers' $list.ToArray() } Set-Prop $data 'model' $DefaultModel Write-Utf8File $file ((ConvertTo-JsonText $data 0) + "`n") return ,$warnings.ToArray() } # 读取当前配置: 返回 @{ ClaudeKey; GptKey; BaseUrl; Model } function Read-CurrentConfig { $r = @{ ClaudeKey = ''; GptKey = ''; BaseUrl = ''; Model = '' } try { $data = (Read-ConfigFile $S.ConfigFile).Data } catch { return $r } $prov = Get-Prop $data 'provider' $c = Get-Prop (Get-Prop $prov $ProviderClaude) 'options' $g = Get-Prop (Get-Prop $prov $ProviderGpt) 'options' $ck = Get-Prop $c 'apiKey'; if ($ck -is [string]) { $r.ClaudeKey = $ck } $gk = Get-Prop $g 'apiKey'; if ($gk -is [string]) { $r.GptKey = $gk } $u = Get-Prop $c 'baseURL' if (-not ($u -is [string]) -or -not $u) { $u = Get-Prop $g 'baseURL' } if ($u -is [string]) { $r.BaseUrl = $u } $m = Get-Prop $data 'model' if ($m -is [string]) { $r.Model = $m } return $r } function Choose-Families { Say ' ' '' '请选择要配置的模型家族:' 'White' Say '' Say ' ' '' '⚡' 'Yellow' ' ' '' '1)' 'Cyan' ' Claude + GPT ' '' '(回车默认)' 'DarkGray' Say ' ' '' '⚡' 'Yellow' ' ' '' '2)' 'Cyan' ' 仅 Claude' '' Say ' ' '' '⚡' 'Yellow' ' ' '' '3)' 'Cyan' ' 仅 GPT' '' Say '' SayN ' ' '' '请输入选项 [回车=1]: ' 'White' $choice = Read-Line $sel = $null switch ($choice) { '' { $sel = @{ Claude = $true; Gpt = $true; Label = 'Claude + GPT' } } '1' { $sel = @{ Claude = $true; Gpt = $true; Label = 'Claude + GPT' } } '2' { $sel = @{ Claude = $true; Gpt = $false; Label = '仅 Claude' } } '3' { $sel = @{ Claude = $false; Gpt = $true; Label = '仅 GPT' } } default { Print-Status 'error' '无效选项,已取消'; return $null } } Say '' Print-Status 'ok' "已选择: $($sel.Label)" return $sel } function Configure-Api { Say '' Print-Status 'info' '⚡ 配置 闪电API (Claude + GPT)' Say '' Resolve-ConfigPath $cur = Read-CurrentConfig if ($cur.ClaudeKey) { Print-Status 'info' "当前 Claude Key: $(Mask-Key $cur.ClaudeKey)" } if ($cur.GptKey) { Print-Status 'info' "当前 GPT Key: $(Mask-Key $cur.GptKey)" } if ($cur.BaseUrl) { Print-Status 'info' "当前 BASE URL: $($cur.BaseUrl)" } Say '' $sel = Choose-Families if ($null -eq $sel) { return $false } Say '' # ── 选择线路 ── Say ' ' '' '请选择 API 线路:' 'White' Say '' Say ' ' '' '⚡' 'Yellow' ' ' '' '1)' 'Cyan' ' ' '' '国内直连' 'Green' ' - https://ai.flashapi.top' '' Say ' ' '' '服务器位于香港,国内可直连,无需翻墙' 'DarkGray' Say '' Say ' ' '' '⚡' 'Yellow' ' ' '' '2)' 'Cyan' ' ' '' '海外CDN' 'Green' ' - https://cdn.flashapi.top' '' Say ' ' '' '全球CDN加速,适合海外用户' 'DarkGray' Say '' SayN ' ' '' '请输入选项 (1/2): ' 'White' $choice = Read-Line $baseUrl = '' switch ($choice) { '1' { $baseUrl = 'https://ai.flashapi.top' } '2' { $baseUrl = 'https://cdn.flashapi.top' } default { Print-Status 'error' '无效选项,已取消'; return $false } } Say '' Print-Status 'info' "已选择: $baseUrl" Say '' # ── 令牌分组提醒 ── Say ' ' '' '╔══════════════════════════════════════════════════════╗' 'Yellow' Say ' ' '' '║' 'Yellow' ' ' '' '⚡ 重要提示' 'White' ' ' '' '║' 'Yellow' Say ' ' '' '║' 'Yellow' ' ' '' '║' 'Yellow' Say ' ' '' '║' 'Yellow' ' ' '' '请先在官网' 'DarkGray' ' ' '' 'flashapi.top' 'Yellow' ' ' '' '创建' 'DarkGray' ' ' '' '令牌分组' 'Green' ' ' '' '║' 'Yellow' Say ' ' '' '║' 'Yellow' ' ' '' '未创建令牌分组将无法获取模型列表!' 'DarkGray' ' ' '' '║' 'Yellow' Say ' ' '' '╚══════════════════════════════════════════════════════╝' 'Yellow' Say '' # ── 输入 API Key (密码遮盖) ── $claudeKey = '' $gptKey = '' if ($sel.Claude) { $claudeKey = Read-Secret ' 请输入 Claude 分组 API Key: ' if (-not $claudeKey) { Print-Status 'error' 'API Key 不能为空,已取消'; return $false } } if ($sel.Gpt) { if ($sel.Claude) { $gptKey = Read-Secret ' 请输入 GPT 分组 API Key (回车 = 与 Claude Key 相同): ' if (-not $gptKey) { $gptKey = $claudeKey } } else { $gptKey = Read-Secret ' 请输入 GPT 分组 API Key: ' } if (-not $gptKey) { Print-Status 'error' 'API Key 不能为空,已取消'; return $false } } Say '' # ── 验证 ── $claudeIds = @() $gptIds = @() if ($sel.Claude) { $v = Validate-Key 'claude' $baseUrl $claudeKey if (-not $v.Ok) { return $false } $claudeIds = $v.Ids } if ($sel.Gpt) { $v = Validate-Key 'gpt' $baseUrl $gptKey if (-not $v.Ok) { return $false } $gptIds = $v.Ids } Say '' if ($sel.Claude) { $defaultModel = "$ProviderClaude/$DefaultClaudeModel" } else { $defaultModel = "$ProviderGpt/$DefaultGptModel" } $writeClaudeKey = '' $writeGptKey = '' if ($sel.Claude) { $writeClaudeKey = $claudeKey } if ($sel.Gpt) { $writeGptKey = $gptKey } # ── 写入 OpenCode 配置 ── $warnings = @() try { $warnings = Write-OpencodeConfig $baseUrl $writeClaudeKey $claudeIds $writeGptKey $gptIds $defaultModel } catch { Print-Status 'error' "写入配置失败: $($_.Exception.Message)" Print-Status 'info' '原文件未被修改 (如已备份,可在同目录找到 .bak 文件)' return $false } $leaf = Split-Path -Leaf $S.ConfigFile if ($warnings -contains 'COMMENTS') { Print-Status 'warn' "$leaf 中原有的注释已在重写时丢失 (原文件已备份,可对照恢复)" } if ($warnings -contains 'PROVIDER_RESET') { Print-Status 'warn' '原配置中的 provider 字段不是对象,已重置为仅包含闪电API 供应商' } if ($warnings -contains 'V2_REMOVED') { Print-Status 'info' '已移除 providers (V2 格式) 中旧的闪电API 同名条目,统一写入 provider' } Say '' if ($sel.Claude) { Print-Status 'ok' "Claude Key 已保存: $(Mask-Key $claudeKey) (供应商 $ProviderClaude)" Print-Status 'ok' "Claude BASE URL: $baseUrl/v1 (@ai-sdk/anthropic)" } if ($sel.Gpt) { Print-Status 'ok' "GPT Key 已保存: $(Mask-Key $gptKey) (供应商 $ProviderGpt)" Print-Status 'ok' "GPT BASE URL: $baseUrl/v1 (@ai-sdk/openai, Responses API)" } Print-Status 'ok' "默认模型: $defaultModel" Print-Status 'ok' "推理强度: $DefaultEffort (写在各模型的 options 中;会话内 Ctrl+T 切换档位)" Print-Status 'ok' "配置文件已写入: $($S.ConfigFile)" if ($env:OPENCODE_CONFIG -or $env:OPENCODE_CONFIG_DIR) { Print-Status 'info' '检测到 OPENCODE_CONFIG / OPENCODE_CONFIG_DIR 环境变量,已写入其指向的位置' } Say '' Say ' ' '' '使用方法:' 'White' Say ' ' '' '启动:' 'Cyan' ' opencode' '' Say ' ' '' '快速验证:' 'Cyan' ' opencode run "Reply with exactly: OK"' '' Say ' ' '' '查看模型:' 'Cyan' " opencode models $ProviderClaude / opencode models $ProviderGpt" '' if ($sel.Gpt) { Say ' ' '' '用 GPT:' 'Cyan' " opencode run -m $ProviderGpt/$DefaultGptModel `"你好`"" '' } if ($sel.Claude) { Say ' ' '' '用 Claude:' 'Cyan' " opencode run -m $ProviderClaude/$DefaultClaudeModel `"你好`"" '' } Say ' ' '' '会话中切换:' 'Cyan' ' 输入 /models 选择模型,Ctrl+T 切换推理档位 (low/medium/high/...)' '' if ($sel.Claude) { Say '' Print-Status 'info' '提示: 若 Claude 请求报错 "Requests from the ... framework are not supported",' Say ' ' '' '说明该 Claude Key 所在分组仅限官方客户端;请到 flashapi.top 在允许第三方工具的分组下' 'DarkGray' Say ' ' '' '新建令牌,然后重新运行本脚本选择「2) 配置 闪电API」' 'DarkGray' } return $true } ############################################################################## # 状态检测与菜单 ############################################################################## function Show-StatusSummary { Resolve-ConfigPath Say ' ' '' '当前状态:' 'White' $osLabel = 'windows' if (-not $OnWindows) { $osLabel = ([string][Environment]::OSVersion.Platform).ToLowerInvariant() } Say ' 系统环境: ' '' $osLabel 'Cyan' $ver = Check-OpencodeInstalled if ($null -ne $ver) { Say ' OpenCode: ' '' '已安装' 'Green' " ($ver)" '' } else { Say ' OpenCode: ' '' '未安装' 'Red' } $cur = Read-CurrentConfig if ($cur.ClaudeKey) { Say ' Claude 配置: ' '' '已配置' 'Green' " ($(Mask-Key $cur.ClaudeKey))" '' } else { Say ' Claude 配置: ' '' '未配置' 'Red' } if ($cur.GptKey) { Say ' GPT 配置: ' '' '已配置' 'Green' " ($(Mask-Key $cur.GptKey))" '' } else { Say ' GPT 配置: ' '' '未配置' 'Red' } if ($cur.BaseUrl) { $u = $cur.BaseUrl if ($u.EndsWith('/v1')) { $u = $u.Substring(0, $u.Length - 3) } Say ' API 线路: ' '' $u 'Green' } if ($cur.Model) { Say ' 默认模型: ' '' $cur.Model 'Cyan' } Say ' 配置文件: ' '' $S.ConfigFile 'Cyan' Say '' } function Show-Menu { Say ' ' '' '请选择操作:' 'White' Say '' Say ' ' '' '⚡' 'Yellow' ' ' '' '1)' 'Cyan' ' 安装 OpenCode' '' Say ' ' '' '⚡' 'Yellow' ' ' '' '2)' 'Cyan' ' 配置 闪电API (Claude + GPT)' '' Say ' ' '' '⚡' 'Yellow' ' ' '' '3)' 'Cyan' ' 一键全部安装 (安装 + 配置)' '' Say ' ' '' '⚡' 'Yellow' ' ' '' '0)' 'Cyan' ' 退出' '' Say '' SayN ' ' '' '请输入选项: ' 'White' } function Invoke-Safely { param([scriptblock]$Block) try { & $Block | Out-Null } catch { Print-Status 'error' "出错: $($_.Exception.Message)" } } ############################################################################## # 主流程 ############################################################################## function Main { Add-SessionPath (Join-Path (Join-Path $UserHome '.opencode') 'bin') Print-Banner Show-StatusSummary while ($true) { Show-Menu $choice = Read-Line if ($null -eq $choice) { Say ''; return } switch ($choice) { '1' { Invoke-Safely { Install-Opencode | Out-Null } Say '' } '2' { Invoke-Safely { Configure-Api | Out-Null } Say '' } '3' { Invoke-Safely { Install-Opencode | Out-Null } Say '' Say " $Line" 'DarkGray' $ok = $false try { $ok = Configure-Api } catch { Print-Status 'error' "出错: $($_.Exception.Message)" } if ($ok) { Say '' Say " $Line" 'DarkGray' Say '' Print-Status 'ok' '⚡ 全部完成! 请重新打开终端后运行 opencode' } Say '' } '0' { Say '' Say ' ' '' '⚡' 'Yellow' ' ' '' '感谢使用闪电API,祝你Vibe Coding愉快!' 'White' Say ' ' '' '官网: flashapi.top' 'DarkGray' Say '' return } default { Print-Status 'error' '无效选项,请重新选择' Say '' } } } } Main }