okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
479178e
main
1package tui 2 3import ( 4"albedo/cli/internal/config" 5"albedo/cli/internal/daemon" 6"cmp" 7"context" 8"slices" 9 10 tea"charm.land/bubbletea/v2" 11) 12 13type customProvider struct { 14ID string 15Label string 16Detail string 17Extension string 18DefaultName string 19DefaultURL string 20FixedProtocol string 21// FixedEndpoint skips the base url: the extension knows where it sends. 22FixedEndpoint bool 23// Scoped asks for a cloud project and location instead of a base url and 24// api key: the extension signs in with credentials the daemon host holds. 25Scoped bool 26// Optional lets the base url and api key stay blank, so the extension 27// falls back to the daemon host's own environment. 28Optional bool 29} 30 31var customProviders = []customProvider { 32 { 33ID :"add-alibaba" , 34Label :"add or update an Alibaba provider" , 35Detail :"token plan · Qwen, DeepSeek, GLM" , 36Extension :"alibaba" , 37DefaultName :"alibaba" , 38DefaultURL :"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1" , 39FixedProtocol :"chat_completions" , 40 }, 41 { 42ID :"add-anthropic" , 43Label :"add or update an Anthropic API key" , 44Detail :"console billing · Claude models" , 45Extension :"claude" , 46DefaultName :"anthropic" , 47FixedProtocol :"chat_completions" , 48FixedEndpoint :true , 49 }, 50 { 51ID :"add-bedrock" , 52Label :"add or update an Amazon Bedrock provider" , 53Detail :"aws credentials · Claude models" , 54Extension :"bedrock" , 55DefaultName :"bedrock" , 56FixedProtocol :"chat_completions" , 57Optional :true , 58 }, 59 { 60ID :"add-vertex" , 61Label :"add or update a Google Vertex AI provider" , 62Detail :"application default credentials · Gemini models" , 63Extension :"vertex" , 64DefaultName :"vertex" , 65FixedProtocol :"chat_completions" , 66Scoped :true , 67 }, 68 { 69ID :"add-openai" , 70Label :"add or update an OpenAI-compatible provider" , 71Extension :"openai" , 72DefaultURL :"https://api.openai.com/v1" , 73 }, 74} 75 76func customProviderByID (id string ) (customProvider ,bool ) { 77for _ ,p := range customProviders { 78if p .ID == id { 79return p ,true 80 } 81 } 82return customProvider {},false 83} 84 85// custom finds the api-key provider the draft's extension belongs to. 86func (m LoginModel )custom () (customProvider ,bool ) { 87i := slices .IndexFunc (customProviders ,func (p customProvider )bool {return p .Extension == m .Draft .Extension }) 88if i < 0 { 89return customProvider {},false 90 } 91return customProviders [i ],true 92} 93 94func (m LoginModel )optionalKey ()bool { 95p ,_ := m .custom () 96return p .Optional 97} 98 99func (m LoginModel )isFixedProtocol ()bool { 100p ,ok := m .custom () 101return ok && p .FixedProtocol != "" 102} 103 104// askEndpoint asks for the base url, or straight for the api key when the 105// extension has a fixed endpoint. 106func (m * LoginModel )askEndpoint () tea.Cmd { 107p ,_ := m .custom () 108switch { 109case p .Scoped : 110return m .askProject () 111case p .FixedEndpoint : 112return m .askAPIKey () 113 } 114m .Step = StepBaseURL 115m .TextInput .Placeholder = "" 116if p .Optional { 117m .TextInput .Placeholder = "blank: your AWS region" 118 } 119return m .promptInput (m .Draft .BaseURL ,false ) 120} 121 122func (m * LoginModel )askAPIKey () tea.Cmd { 123m .Step = StepAPIKey 124m .TextInput .Placeholder = "" 125if p ,_ := m .custom ();p .Optional { 126m .TextInput .Placeholder = "blank: your AWS credentials" 127 } 128return m .promptInput ("" ,true ) 129} 130 131func (m * LoginModel )askProject () tea.Cmd { 132m .Step = StepProject 133m .TextInput .Placeholder = "blank: GOOGLE_VERTEX_PROJECT" 134return m .promptInput (deref (m .Draft .Project ),false ) 135} 136 137func (m * LoginModel )askLocation () tea.Cmd { 138m .Step = StepLocation 139m .TextInput .Placeholder = "blank: GOOGLE_VERTEX_LOCATION" 140return m .promptInput (deref (m .Draft .Location ),false ) 141} 142 143// optionalText is the pointer a profile stores for a field left blank or set. 144func optionalText (value string )* string { 145if value == "" { 146return nil 147 } 148return & value 149} 150 151func deref (value * string )string { 152if value == nil { 153return "" 154 } 155return * value 156} 157 158func (m * LoginModel )advanceToModels () tea.Cmd { 159m .Catalog = nil 160m .CatalogNote = "" 161m .Step = StepModels 162m .Generation = nextPageGeneration () 163return m .fetchCatalogCmd (m .Draft .Extension ,m .Draft .BaseURL ,m .Generation ) 164} 165 166func (m * LoginModel )startCustomProvider (p customProvider ) tea.Cmd { 167m .Name = p .DefaultName 168m .Draft = config.Settings {Extension :p .Extension ,BaseURL :p .DefaultURL ,Protocol :cmp .Or (p .FixedProtocol ,"responses" )} 169m .Step = StepName 170m .TextInput .Placeholder = p .DefaultName 171return m .promptInput ("" ,false ) 172} 173 174// useProfile re-selects a saved profile: a sign-in provider with no accounts 175// left cannot run without an api key, so it signs in first. 176func (m * LoginModel )useProfile (name string ,settings config.Settings ) tea.Cmd { 177if login ,ok := m .signInFor (settings .Extension );ok && ! hasKey (settings )&& m .signedOut (settings .Extension ) { 178return m .startSignIn (login ) 179 } 180m .Step = StepSaving 181return m .saveProviderCmd (name ,settings ) 182} 183 184// applyHint routes a name given to `/login <name>`: a saved profile is chosen as 185// it stands, a sign-in provider starts its sign-in, anything else is a new 186// openai-compatible provider. 187func (m * LoginModel )applyHint () tea.Cmd { 188hint := m .Hint 189m .Hint = "" 190name ,err := config .ValidateProviderName (hint ) 191if err != nil { 192m .Error = err .Error () 193m .Step = StepChoose 194return m .ChoosePicker .Init () 195 } 196m .Name = name 197if saved ,ok := m .Profiles .Providers [name ];ok { 198m .Draft = saved 199return m .useProfile (name ,saved ) 200 } 201if login ,ok := m .signInFor (name );ok { 202return m .startSignIn (login ) 203 } 204return m .askEndpoint () 205} 206 207func (m * LoginModel )buildProtocolPicker () { 208items := []PickerItem { 209 {ID :"responses" ,Label :"responses" ,Detail :"OpenAI Responses API" }, 210 {ID :"chat_completions" ,Label :"chat completions" ,Detail :"widely supported by compatible endpoints" }, 211 } 212m .ProtocolPicker = newLoginPick ("api protocol" ,items ,m .Draft .Protocol ,m .Width ,m .Height ) 213} 214 215func (m * LoginModel )buildModelPicker () { 216items := make ([]PickerItem ,0 ,len (m .Catalog )+ 1 ) 217for _ ,name := range m .Catalog { 218items = append (items ,PickerItem {ID :"model:" + name ,Label :name ,Note :"from the models.dev catalog" }) 219 } 220items = append (items ,PickerItem {ID :"manual" ,Label :"enter a model ID manually" ,Note :"type a model ID yourself" }) 221m .ModelPicker = newLoginPick ("model" ,items ,"model:" + m .Draft .Model ,m .Width ,m .Height ) 222} 223 224// hasKey reports a profile with an api key, entered now or held by the daemon. 225func hasKey (settings config.Settings )bool { 226return settings .APIKey != "" || settings .HasKey 227} 228 229func (m LoginModel )fetchCatalogCmd (ext ,endpoint string ,gen int ) tea.Cmd { 230return func () tea.Msg { 231note := "" 232if m .Conn == nil { 233return loginModelsLoadedMsg {Note :"Could not load the models.dev catalog. Enter a model ID manually." ,Gen :gen } 234 } 235models ,err := daemon .ListModels (m .readCtx ,m .Conn ,ext ,endpoint ) 236names := make ([]string ,len (models )) 237for i ,model := range models { 238names [i ]= model .ID 239 } 240if err != nil { 241return loginModelsLoadedMsg {Err :err ,Note :"Could not load the models.dev catalog. Enter a model ID manually." ,Gen :gen } 242 } 243if len (names )== 0 { 244note = "No matching models in models.dev. Enter a model ID manually." 245 } 246return loginModelsLoadedMsg {Models :names ,Note :note ,Gen :gen } 247 } 248} 249 250// saveProviderCmd saves the profile and optional key through the daemon. 251func (m LoginModel )saveProviderCmd (name string ,settings config.Settings ) tea.Cmd { 252return func () tea.Msg { 253validated ,err := settings .Validate () 254if err != nil { 255return providerSavedMsg {Gen :m .Generation ,Name :name ,Settings :settings ,Err :err } 256 } 257err = daemon .SaveAndSelectProvider (context .Background (),m .Conn ,name ,validated ,m .Profiles .ETag ) 258return providerSavedMsg {Gen :m .Generation ,Name :name ,Settings :validated ,Err :err } 259 } 260}