projects/aports·blob master · refs

4237 lines · 133.4 KiB

1From 3fa1eb3c9dcf0eab41880749aa9bfc619d30ec39 Mon Sep 17 00:00:00 2001
2From: Aster Boese <[email protected]>
3Date: Wed, 17 Jun 2026 21:39:21 +0200
4Subject: [PATCH] strip out code calling LLMs
5
6I don't want to help debug random failures that come from this. These never
7work anyways.
8
9Signed-off-by: Aster Boese <[email protected]>
10---
11 cmd/ipsw/cmd/dyld/dyld_disass.go | 572 --------------------------
12 cmd/ipsw/cmd/macho/macho_disass.go | 445 --------------------
13 internal/ai/acp/acp.go | 579 ---------------------------
14 internal/ai/acp/acp_test.go | 403 -------------------
15 internal/ai/ai.go | 363 -----------------
16 internal/ai/anthropic/anthropic.go | 144 -------
17 internal/ai/copilot/copilot.go | 509 -----------------------
18 internal/ai/gemini/gemini.go | 146 -------
19 internal/ai/ollama/ollama.go | 160 --------
20 internal/ai/openai/openai.go | 131 ------
21 internal/ai/openrouter/openrouter.go | 259 ------------
22 internal/ai/utils/utils.go | 27 --
23 internal/commands/disass/disass.go | 145 -------
24 internal/db/ai/cache.go | 181 ---------
25 internal/model/ai/cache.go | 38 --
26 15 files changed, 4102 deletions(-)
27 delete mode 100644 cmd/ipsw/cmd/dyld/dyld_disass.go
28 delete mode 100644 cmd/ipsw/cmd/macho/macho_disass.go
29 delete mode 100644 internal/ai/acp/acp.go
30 delete mode 100644 internal/ai/acp/acp_test.go
31 delete mode 100644 internal/ai/ai.go
32 delete mode 100644 internal/ai/anthropic/anthropic.go
33 delete mode 100644 internal/ai/copilot/copilot.go
34 delete mode 100644 internal/ai/gemini/gemini.go
35 delete mode 100644 internal/ai/ollama/ollama.go
36 delete mode 100644 internal/ai/openai/openai.go
37 delete mode 100644 internal/ai/openrouter/openrouter.go
38 delete mode 100644 internal/ai/utils/utils.go
39 delete mode 100644 internal/commands/disass/disass.go
40 delete mode 100644 internal/db/ai/cache.go
41 delete mode 100644 internal/model/ai/cache.go
42
43diff --git a/cmd/ipsw/cmd/dyld/dyld_disass.go b/cmd/ipsw/cmd/dyld/dyld_disass.go
44deleted file mode 100644
45index a408f0bf2..000000000
46--- a/cmd/ipsw/cmd/dyld/dyld_disass.go
47+++ /dev/null
48@@ -1,572 +0,0 @@
49-/*
50-Copyright © 2018-2026 blacktop
51-
52-Permission is hereby granted, free of charge, to any person obtaining a copy
53-of this software and associated documentation files (the "Software"), to deal
54-in the Software without restriction, including without limitation the rights
55-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
56-copies of the Software, and to permit persons to whom the Software is
57-furnished to do so, subject to the following conditions:
58-
59-The above copyright notice and this permission notice shall be included in
60-all copies or substantial portions of the Software.
61-
62-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
63-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
64-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
65-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
66-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
67-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
68-THE SOFTWARE.
69-*/
70-package dyld
71-
72-import (
73- "encoding/json"
74- "fmt"
75- "os"
76- "path/filepath"
77- "strings"
78- "time"
79-
80- "github.com/MakeNowJust/heredoc/v2"
81- "github.com/alecthomas/chroma/v2/styles"
82- "github.com/apex/log"
83- "github.com/blacktop/ipsw/internal/ai"
84- dcmd "github.com/blacktop/ipsw/internal/commands/disass"
85- "github.com/blacktop/ipsw/pkg/disass"
86- "github.com/blacktop/ipsw/pkg/dyld"
87- "github.com/pkg/errors"
88- "github.com/spf13/cobra"
89- "github.com/spf13/viper"
90-)
91-
92-func init() {
93- DyldCmd.AddCommand(DisassCmd)
94- // DisassCmd.Flags().Uint64("slide", 0, "dyld_shared_cache slide to remove from --vaddr")
95- DisassCmd.Flags().StringSliceP("image", "i", []string{}, "Dylib(s) to disassemble")
96- DisassCmd.Flags().StringP("symbol", "s", "", "Function to disassemble")
97- DisassCmd.Flags().String("symbol-image", "", "Dylib to search for symbol (speeds up symbol lookup)")
98- DisassCmd.Flags().Uint64P("vaddr", "a", 0, "Virtual address to start disassembling")
99- DisassCmd.Flags().Uint64P("count", "c", 0, "Number of instructions to disassemble")
100- DisassCmd.Flags().Bool("dylibs", false, "Analyze all dylibs loaded by the image as well (could improve accuracy)")
101- DisassCmd.Flags().BoolP("demangle", "d", false, "Demangle symbol names")
102- DisassCmd.Flags().BoolP("dec", "D", false, "Decompile assembly")
103- DisassCmd.Flags().String("dec-lang", "", "Language to decompile to (C, ObjC or Swift)")
104- DisassCmd.Flags().String("dec-llm", "copilot", "LLM provider to use for decompilation (ollama, copilot, etc.)")
105- DisassCmd.RegisterFlagCompletionFunc("dec-llm", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
106- return ai.Providers, cobra.ShellCompDirectiveDefault
107- })
108- DisassCmd.Flags().String("dec-model", "", "LLM model to use for decompilation")
109- DisassCmd.Flags().Bool("dec-nocache", false, "Do not use decompilation cache")
110- DisassCmd.Flags().Float64("dec-temp", 0.2, "LLM temperature for decompilation")
111- DisassCmd.Flags().Float64("dec-top-p", 0.1, "LLM top_p for decompilation")
112- DisassCmd.Flags().Int("dec-retries", 0, "Number of retries for LLM decompilation")
113- DisassCmd.Flags().Duration("dec-retry-backoff", 30*time.Second, "Backoff time between retries (e.g. '30s', '2m')")
114- DisassCmd.Flags().String("dec-theme", "nord", "Decompilation color theme (nord, github, etc)")
115- DisassCmd.RegisterFlagCompletionFunc("dec-theme", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
116- return styles.Names(), cobra.ShellCompDirectiveNoFileComp
117- })
118- DisassCmd.Flags().BoolP("json", "j", false, "Output as JSON")
119- DisassCmd.Flags().BoolP("quiet", "q", false, "Do NOT markup analysis (Faster)")
120- DisassCmd.Flags().Bool("force", false, "Continue to disassemble even if there are analysis errors")
121- DisassCmd.Flags().String("input", "", "Input function JSON file")
122- DisassCmd.Flags().String("cache", "", "Path to .a2s addr to sym cache file (speeds up analysis)")
123- DisassCmd.MarkFlagsMutuallyExclusive("symbol", "vaddr", "input", "image")
124- // DisassCmd.Flags().Bool("replace", false, "Replace .a2s")
125- viper.BindPFlag("dyld.disass.image", DisassCmd.Flags().Lookup("image"))
126- viper.BindPFlag("dyld.disass.symbol", DisassCmd.Flags().Lookup("symbol"))
127- viper.BindPFlag("dyld.disass.symbol-image", DisassCmd.Flags().Lookup("symbol-image"))
128- viper.BindPFlag("dyld.disass.vaddr", DisassCmd.Flags().Lookup("vaddr"))
129- viper.BindPFlag("dyld.disass.count", DisassCmd.Flags().Lookup("count"))
130- viper.BindPFlag("dyld.disass.dylibs", DisassCmd.Flags().Lookup("dylibs"))
131- viper.BindPFlag("dyld.disass.demangle", DisassCmd.Flags().Lookup("demangle"))
132- viper.BindPFlag("dyld.disass.dec", DisassCmd.Flags().Lookup("dec"))
133- viper.BindPFlag("dyld.disass.dec-lang", DisassCmd.Flags().Lookup("dec-lang"))
134- viper.BindPFlag("dyld.disass.dec-llm", DisassCmd.Flags().Lookup("dec-llm"))
135- viper.BindPFlag("dyld.disass.dec-model", DisassCmd.Flags().Lookup("dec-model"))
136- viper.BindPFlag("dyld.disass.dec-nocache", DisassCmd.Flags().Lookup("dec-nocache"))
137- viper.BindPFlag("dyld.disass.dec-temp", DisassCmd.Flags().Lookup("dec-temp"))
138- viper.BindPFlag("dyld.disass.dec-top-p", DisassCmd.Flags().Lookup("dec-top-p"))
139- viper.BindPFlag("dyld.disass.dec-retries", DisassCmd.Flags().Lookup("dec-retries"))
140- viper.BindPFlag("dyld.disass.dec-retry-backoff", DisassCmd.Flags().Lookup("dec-retry-backoff"))
141- viper.BindPFlag("dyld.disass.dec-theme", DisassCmd.Flags().Lookup("dec-theme"))
142- viper.BindPFlag("dyld.disass.json", DisassCmd.Flags().Lookup("json"))
143- viper.BindPFlag("dyld.disass.quiet", DisassCmd.Flags().Lookup("quiet"))
144- viper.BindPFlag("dyld.disass.force", DisassCmd.Flags().Lookup("force"))
145- viper.BindPFlag("dyld.disass.color", DisassCmd.Flags().Lookup("color"))
146- viper.BindPFlag("dyld.disass.input", DisassCmd.Flags().Lookup("input"))
147- viper.BindPFlag("dyld.disass.cache", DisassCmd.Flags().Lookup("cache"))
148- // viper.BindPFlag("dyld.disass.replace", DisassCmd.Flags().Lookup("replace"))
149-}
150-
151-// DisassCmd represents the disass command
152-var DisassCmd = &cobra.Command{
153- Use: "disass <DSC>",
154- Aliases: []string{"dis"},
155- Short: "Disassemble at symbol/vaddr",
156- Example: heredoc.Doc(`
157- # Disassemble all images in dyld_shared_cache
158- ❯ ipsw dsc disass DSC
159- # Disassemble a few dylibs in dyld_shared_cache (NOTE: multiple -i flags OR comma separated dylibs)
160- ❯ ipsw dsc disass DSC --image libsystem_kernel.dylib --image libsystem_platform.dylib,libsystem_pthread.dylib
161- # Disassemble a symbol in dyld_shared_cache (NOTE: supply --symbol-image 'libsystem_malloc.dylib' for faster lookup)
162- ❯ ipsw dsc disass DSC --symbol _malloc
163- # Disassemble a function at a virtual address in dyld_shared_cache
164- ❯ ipsw dsc disass DSC --vaddr 0x1b19d6940
165- # Disassemble a function at a virtual address in dyld_shared_cache and output as JSON
166- ❯ ipsw dsc disass DSC --vaddr 0x1b19d6940 --json
167- # Disassemble a function at a virtual address in dyld_shared_cache and demangle symbol names
168- ❯ ipsw dsc disass DSC --vaddr 0x1b19d6940 --demangle
169- # Disassemble a function at a virtual address in dyld_shared_cache and do NOT markup analysis (Faster)
170- ❯ ipsw dsc disass DSC --vaddr 0x1b19d6940 --quiet
171- # Decompile a function at a virtual address in dyld_shared_cache (via GitHub Copilot)
172- ❯ ipsw dsc disass DSC --vaddr 0x1b19d6940 --dec --dec-model "Claude 3.7 Sonnet"
173- # Decompile a function using OpenRouter to access various models
174- ❯ ipsw dsc disass DSC --vaddr 0x1b19d6940 --dec --dec-llm openrouter --dec-model "OpenAI: GPT-4o-mini"`),
175- Args: cobra.ExactArgs(1),
176- ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
177- return getDSCs(toComplete), cobra.ShellCompDirectiveDefault
178- },
179- SilenceErrors: true,
180- RunE: func(cmd *cobra.Command, args []string) error {
181-
182- var data []byte
183- var middleAddr uint64
184- var image *dyld.CacheImage
185- var images []*dyld.CacheImage
186-
187- // flags
188- imageNames := viper.GetStringSlice("dyld.disass.image")
189- symbolName := viper.GetString("dyld.disass.symbol")
190- symbolImageName := viper.GetString("dyld.disass.symbol-image")
191- startAddr := viper.GetUint64("dyld.disass.vaddr")
192- instructions := viper.GetUint64("dyld.disass.count")
193-
194- decompile := viper.GetBool("dyld.disass.dec")
195- demangleFlag := viper.GetBool("dyld.disass.demangle")
196- asJSON := viper.GetBool("dyld.disass.json")
197- quiet := viper.GetBool("dyld.disass.quiet")
198-
199- tempFlagSet := dcmd.FlagWasProvided(cmd, "dec-temp", "dyld.disass.dec-temp")
200- topPFlagSet := dcmd.FlagWasProvided(cmd, "dec-top-p", "dyld.disass.dec-top-p")
201-
202- funcFile := viper.GetString("dyld.disass.input")
203- cacheFile := viper.GetString("dyld.disass.cache")
204- // validate flags
205- if len(symbolImageName) > 0 && len(symbolName) == 0 {
206- return fmt.Errorf("you must also supply a --symbol with --symbol-image flag")
207- }
208- if viper.GetString("dyld.disass.dec-llm") != "" {
209- if !ai.IsValidProvider(viper.GetString("dyld.disass.dec-llm")) {
210- return fmt.Errorf("invalid LLM provider '%s', must be one of: %s", viper.GetString("dyld.disass.dec-llm"), strings.Join(ai.Providers, ", "))
211- }
212- }
213-
214- dscPath := filepath.Clean(args[0])
215-
216- fileInfo, err := os.Lstat(dscPath)
217- if err != nil {
218- return fmt.Errorf("file %s does not exist", dscPath)
219- }
220-
221- // Check if file is a symlink
222- if fileInfo.Mode()&os.ModeSymlink != 0 {
223- symlinkPath, err := os.Readlink(dscPath)
224- if err != nil {
225- return errors.Wrapf(err, "failed to read symlink %s", dscPath)
226- }
227- // TODO: this seems like it would break
228- linkParent := filepath.Dir(dscPath)
229- linkRoot := filepath.Dir(linkParent)
230-
231- dscPath = filepath.Join(linkRoot, symlinkPath)
232- }
233-
234- f, err := dyld.Open(dscPath)
235- if err != nil {
236- return err
237- }
238- defer f.Close()
239-
240- if !f.IsArm64() {
241- log.Errorf("can only disassemble arm64 caches")
242- return nil
243- }
244-
245- if !quiet || len(symbolName) > 0 {
246- if len(cacheFile) == 0 {
247- cacheFile = dscPath + ".a2s"
248- }
249- if err := f.OpenOrCreateA2SCache(cacheFile); err != nil {
250- return err
251- }
252- }
253-
254- if len(symbolName) == 0 && startAddr == 0 && len(funcFile) == 0 { /* DEFAULT: disassemble image(s) */
255-
256- if len(imageNames) == 0 {
257- images = f.Images // disassemble ALL images
258- } else {
259- for _, name := range imageNames {
260- image, err := f.Image(name)
261- if err != nil {
262- return fmt.Errorf("failed to find image %s: %v", name, err)
263- }
264- images = append(images, image)
265- }
266- }
267-
268- for _, image := range images {
269- m, err := image.GetMacho()
270- if err != nil {
271- return err
272- }
273- defer m.Close()
274-
275- for idx, fn := range m.GetFunctions() {
276- data, err := m.GetFunctionData(fn)
277- if err != nil {
278- log.Errorf("failed to get data for function: %v", err)
279- continue
280- }
281-
282- engine := dyld.NewDyldDisass(f, &disass.Config{
283- Image: image.Name,
284- Data: data,
285- StartAddress: fn.StartAddr,
286- Middle: 0,
287- AsJSON: asJSON,
288- Demangle: demangleFlag,
289- Quiet: quiet,
290- Color: viper.GetBool("color") && !viper.GetBool("no-color") && !decompile,
291- })
292-
293- if !quiet {
294- //***********************
295- //* First pass ANALYSIS *
296- //***********************
297- if err := image.Analyze(); err != nil {
298- if !viper.GetBool("dyld.disass.force") {
299- return fmt.Errorf("failed to analyze image %s: %v (use --force to continue anyway)", filepath.Base(image.Name), err)
300- }
301- }
302- if err := engine.Triage(); err != nil {
303- return fmt.Errorf("first pass triage failed: %v", err)
304- }
305- if viper.GetBool("dyld.disass.dylibs") {
306- for _, img := range engine.Dylibs() {
307- if err := img.Analyze(); err != nil {
308- if !viper.GetBool("dyld.disass.force") {
309- return fmt.Errorf("failed to analyze image %s: %v (use --force to continue anyway)", filepath.Base(img.Name), err)
310- }
311- }
312- }
313- }
314- } else {
315- if !asJSON {
316- if idx == 0 {
317- fmt.Printf("sub_%x:\n", fn.StartAddr)
318- } else {
319- fmt.Printf("\nsub_%x:\n", fn.StartAddr)
320- }
321- }
322- }
323- //***************
324- //* DISASSEMBLE *
325- //***************
326- asm := disass.Disassemble(engine)
327- if decompile && len(asm) > 0 {
328- decmp, err := dcmd.Decompile(asm, &dcmd.Config{
329- UUID: f.UUID.String(),
330- LLM: viper.GetString("dyld.disass.dec-llm"),
331- Language: viper.GetString("dyld.disass.dec-lang"),
332- Model: viper.GetString("dyld.disass.dec-model"),
333- Temperature: viper.GetFloat64("dyld.disass.dec-temp"),
334- TemperatureSet: tempFlagSet,
335- TopP: viper.GetFloat64("dyld.disass.dec-top-p"),
336- TopPSet: topPFlagSet,
337- Stream: false,
338- DisableCache: viper.GetBool("dyld.disass.dec-nocache"),
339- Verbose: viper.GetBool("verbose"),
340- Color: viper.GetBool("color") && !viper.GetBool("no-color"),
341- Theme: viper.GetString("dyld.disass.dec-theme"),
342- MaxRetries: viper.GetInt("dyld.disass.dec-retries"),
343- RetryBackoff: viper.GetDuration("dyld.disass.dec-retry-backoff"),
344- })
345- if err != nil {
346- return fmt.Errorf("failed to decompile via llm: %v", err)
347- }
348- fmt.Println(decmp)
349- } else {
350- fmt.Println(asm)
351- }
352- }
353- }
354-
355- } else { /* disassemble symbol, vaddr or input func file */
356-
357- if len(symbolName) > 0 {
358- // Calculate start address from symbol
359- log.Info("Locating symbol: " + symbolName)
360- if len(symbolImageName) > 0 {
361- image, err = f.Image(symbolImageName)
362- if err != nil {
363- return fmt.Errorf("failed to find image %s: %v", symbolImageName, err)
364- }
365- sym, err := image.GetSymbol(symbolName)
366- if err != nil {
367- return err
368- }
369- startAddr = sym.Address
370- } else {
371- startAddr, image, err = f.GetSymbolAddress(symbolName)
372- if err != nil {
373- return err
374- }
375- log.Infof("Found symbol in %s", filepath.Base(image.Name))
376- }
377- }
378-
379- if len(funcFile) > 0 { // INPUT FUNC FILE
380- funcFile = filepath.Clean(funcFile)
381- fdata, _ := os.ReadFile(funcFile)
382-
383- var funcs []dscFunc
384- if err := json.Unmarshal(fdata, &funcs); err != nil {
385- return fmt.Errorf("failed to parse function JSON file %s: %v", funcFile, err)
386- }
387-
388- for _, fn := range funcs {
389- funcImage, err := resolveDscFuncImage(f, fn)
390- if err != nil {
391- return err
392- }
393-
394- uuid, off, err := f.GetOffset(fn.Start)
395- if err != nil {
396- return err
397- }
398-
399- data, err := f.ReadBytesForUUID(uuid, int64(off), fn.Size)
400- if err != nil {
401- return err
402- }
403-
404- engine := dyld.NewDyldDisass(f, &disass.Config{
405- Image: funcImage.Name,
406- Data: data,
407- StartAddress: fn.Start,
408- Middle: 0,
409- AsJSON: asJSON,
410- Demangle: demangleFlag,
411- Quiet: quiet,
412- Color: viper.GetBool("color") && !viper.GetBool("no-color") && !decompile,
413- })
414-
415- if !quiet {
416- //***********************
417- //* First pass ANALYSIS *
418- //***********************
419- image = funcImage
420- if err := image.Analyze(); err != nil {
421- if !viper.GetBool("dyld.disass.force") {
422- return fmt.Errorf("failed to analyze image %s: %v (use --force to continue anyway)", filepath.Base(image.Name), err)
423- }
424- }
425- if err := engine.Triage(); err != nil {
426- return fmt.Errorf("first pass triage failed: %v", err)
427- }
428- if viper.GetBool("dyld.disass.dylibs") {
429- for _, img := range engine.Dylibs() {
430- if err := img.Analyze(); err != nil {
431- if !viper.GetBool("dyld.disass.force") {
432- return fmt.Errorf("failed to analyze image %s: %v (use --force to continue anyway)", filepath.Base(img.Name), err)
433- }
434- }
435- }
436- }
437- } else {
438- if !asJSON {
439- if len(fn.Name) > 0 {
440- fmt.Printf("\n%s:\n", fn.Name)
441- } else {
442- fmt.Printf("\nsub_%x:\n", fn.Start)
443- }
444- }
445- }
446-
447- //***************
448- //* DISASSEMBLE *
449- //***************
450- asm := disass.Disassemble(engine)
451- if decompile && len(asm) > 0 {
452- decmp, err := dcmd.Decompile(asm, &dcmd.Config{
453- UUID: f.UUID.String(),
454- LLM: viper.GetString("dyld.disass.dec-llm"),
455- Language: viper.GetString("dyld.disass.dec-lang"),
456- Model: viper.GetString("dyld.disass.dec-model"),
457- Temperature: viper.GetFloat64("dyld.disass.dec-temp"),
458- TemperatureSet: tempFlagSet,
459- TopP: viper.GetFloat64("dyld.disass.dec-top-p"),
460- TopPSet: topPFlagSet,
461- Stream: false,
462- DisableCache: viper.GetBool("dyld.disass.dec-nocache"),
463- Verbose: viper.GetBool("verbose"),
464- Color: viper.GetBool("color") && !viper.GetBool("no-color"),
465- Theme: viper.GetString("dyld.disass.dec-theme"),
466- MaxRetries: viper.GetInt("dyld.disass.dec-retries"),
467- RetryBackoff: viper.GetDuration("dyld.disass.dec-retry-backoff"),
468- })
469- if err != nil {
470- return fmt.Errorf("failed to decompile via llm: %v", err)
471- }
472- fmt.Println(decmp)
473- } else {
474- fmt.Println(asm)
475- }
476- }
477- } else { // SYMBOL or VAADDR
478- /*
479- * Read in data to disassemble
480- */
481- if instructions > 0 {
482- uuid, off, err := f.GetOffset(startAddr)
483- if err != nil {
484- return err
485- }
486- data, err = f.ReadBytesForUUID(uuid, int64(off), instructions*4)
487- if err != nil {
488- return err
489- }
490- } else {
491- if image, err = f.GetImageContainingVMAddr(startAddr); err == nil {
492- if !quiet {
493- if err := image.Analyze(); err != nil {
494- if !viper.GetBool("dyld.disass.force") {
495- return fmt.Errorf("failed to analyze image %s: %v (use --force to continue anyway)", filepath.Base(image.Name), err)
496- }
497- }
498- }
499- m, err := image.GetMacho()
500- if err != nil {
501- return err
502- }
503- defer m.Close()
504- fn, err := m.GetFunctionForVMAddr(startAddr)
505- if err != nil {
506- return fmt.Errorf("failed to find function for 0x%x: %v", startAddr, err)
507- }
508- uuid, soff, err := f.GetOffset(fn.StartAddr)
509- if err != nil {
510- return err
511- }
512- data, err = f.ReadBytesForUUID(uuid, int64(soff), uint64(fn.EndAddr-fn.StartAddr))
513- if err != nil {
514- return err
515- }
516- if startAddr != fn.StartAddr {
517- middleAddr = startAddr
518- startAddr = fn.StartAddr
519- }
520- } else { // vmaddr not in image; fallback to reading instructions but set count to 100
521- log.WithError(err).Warn("failed to find image containing vmaddr; falling back to reading raw instructions")
522- if instructions == 0 {
523- log.Warn("setting instruction count to 100, to change use --count flag")
524- instructions = 100
525- }
526- uuid, off, err := f.GetOffset(startAddr)
527- if err != nil {
528- return err
529- }
530- data, err = f.ReadBytesForUUID(uuid, int64(off), instructions*4)
531- if err != nil {
532- return err
533- }
534- }
535- }
536- if data == nil {
537- log.Fatal("failed to disassemble")
538- }
539-
540- // // Apply slide
541- // if slide > 0 {
542- // startAddr = startAddr - slide
543- // }
544- var imageName string
545- if image != nil {
546- imageName = image.Name
547- }
548-
549- engine := dyld.NewDyldDisass(f, &disass.Config{
550- Image: imageName,
551- Data: data,
552- StartAddress: startAddr,
553- Middle: middleAddr,
554- AsJSON: asJSON,
555- Demangle: demangleFlag,
556- Quiet: quiet,
557- Color: viper.GetBool("color") && !viper.GetBool("no-color") && !decompile,
558- })
559-
560- if !quiet {
561- //***********************
562- //* First pass ANALYSIS *
563- //***********************
564- if err := engine.Triage(); err != nil {
565- return fmt.Errorf("first pass triage failed: %v (use --force to continue anyway)", err)
566- }
567- if viper.GetBool("dyld.disass.dylibs") {
568- for _, img := range engine.Dylibs() {
569- if err := img.Analyze(); err != nil {
570- if !viper.GetBool("dyld.disass.force") {
571- return fmt.Errorf("failed to analyze image %s: %v (use --force to continue anyway)", filepath.Base(img.Name), err)
572- }
573- }
574- }
575- }
576- } else {
577- if !asJSON {
578- if len(symbolName) > 0 {
579- fmt.Printf("%s:\n", symbolName)
580- } else {
581- fmt.Printf("sub_%x:\n", startAddr)
582- }
583- }
584- }
585-
586- //***************
587- //* DISASSEMBLE *
588- //***************
589- asm := disass.Disassemble(engine)
590- if decompile && len(asm) > 0 {
591- decmp, err := dcmd.Decompile(asm, &dcmd.Config{
592- UUID: f.UUID.String(),
593- LLM: viper.GetString("dyld.disass.dec-llm"),
594- Language: viper.GetString("dyld.disass.dec-lang"),
595- Model: viper.GetString("dyld.disass.dec-model"),
596- Temperature: viper.GetFloat64("dyld.disass.dec-temp"),
597- TemperatureSet: tempFlagSet,
598- TopP: viper.GetFloat64("dyld.disass.dec-top-p"),
599- TopPSet: topPFlagSet,
600- Stream: false,
601- DisableCache: viper.GetBool("dyld.disass.dec-nocache"),
602- Verbose: viper.GetBool("verbose"),
603- Color: viper.GetBool("color") && !viper.GetBool("no-color"),
604- Theme: viper.GetString("dyld.disass.dec-theme"),
605- MaxRetries: viper.GetInt("dyld.disass.dec-retries"),
606- RetryBackoff: viper.GetDuration("dyld.disass.dec-retry-backoff"),
607- })
608- if err != nil {
609- return fmt.Errorf("failed to decompile via llm: %v", err)
610- }
611- fmt.Println(decmp)
612- } else {
613- fmt.Println(asm)
614- }
615- }
616- }
617-
618- return nil
619- },
620-}
621diff --git a/cmd/ipsw/cmd/macho/macho_disass.go b/cmd/ipsw/cmd/macho/macho_disass.go
622deleted file mode 100644
623index 303a9e941..000000000
624--- a/cmd/ipsw/cmd/macho/macho_disass.go
625+++ /dev/null
626@@ -1,445 +0,0 @@
627-/*
628-Copyright © 2018-2026 blacktop
629-
630-Permission is hereby granted, free of charge, to any person obtaining a copy
631-of this software and associated documentation files (the "Software"), to deal
632-in the Software without restriction, including without limitation the rights
633-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
634-copies of the Software, and to permit persons to whom the Software is
635-furnished to do so, subject to the following conditions:
636-
637-The above copyright notice and this permission notice shall be included in
638-all copies or substantial portions of the Software.
639-
640-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
641-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
642-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
643-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
644-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
645-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
646-THE SOFTWARE.
647-*/
648-package macho
649-
650-import (
651- "context"
652- "fmt"
653- "path/filepath"
654- "strings"
655- "time"
656-
657- "github.com/alecthomas/chroma/v2/styles"
658- "github.com/apex/log"
659- "github.com/blacktop/go-macho"
660- "github.com/blacktop/go-macho/types"
661- "github.com/blacktop/ipsw/internal/ai"
662- dcmd "github.com/blacktop/ipsw/internal/commands/disass"
663- mcmd "github.com/blacktop/ipsw/internal/commands/macho"
664- "github.com/blacktop/ipsw/internal/magic"
665- "github.com/blacktop/ipsw/pkg/disass"
666- "github.com/caarlos0/ctrlc"
667- "github.com/pkg/errors"
668- "github.com/spf13/cobra"
669- "github.com/spf13/viper"
670-)
671-
672-func init() {
673- MachoCmd.AddCommand(machoDisassCmd)
674- // machoDisassCmd.Flags().Uint64("slide", 0, "MachO slide to remove from --vaddr")
675- machoDisassCmd.Flags().String("arch", "", "Which architecture to use for fat/universal MachO")
676- machoDisassCmd.Flags().BoolP("entry", "e", false, "Disassemble entry point")
677- machoDisassCmd.Flags().StringP("symbol", "s", "", "Function to disassemble")
678- machoDisassCmd.Flags().Uint64P("vaddr", "a", 0, "Virtual address to start disassembling")
679- machoDisassCmd.Flags().Uint64P("off", "o", 0, "File offset to start disassembling")
680- machoDisassCmd.Flags().Uint64P("count", "c", 0, "Number of instructions to disassemble")
681- machoDisassCmd.Flags().BoolP("demangle", "d", false, "Demangle symbol names")
682- machoDisassCmd.Flags().BoolP("dec", "D", false, "Decompile assembly")
683- machoDisassCmd.Flags().String("dec-lang", "", "Language to decompile to (C, ObjC or Swift)")
684- machoDisassCmd.Flags().String("dec-llm", "copilot", "LLM provider to use for decompilation (ollama, copilot, etc.)")
685- machoDisassCmd.RegisterFlagCompletionFunc("dec-llm", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
686- return ai.Providers, cobra.ShellCompDirectiveDefault
687- })
688- machoDisassCmd.Flags().String("dec-model", "", "LLM model to use for decompilation")
689- machoDisassCmd.Flags().Bool("dec-nocache", false, "Do not use decompilation cache")
690- machoDisassCmd.Flags().Float64("dec-temp", 0.2, "LLM temperature for decompilation")
691- machoDisassCmd.Flags().Float64("dec-top-p", 0.1, "LLM top_p for decompilation")
692- machoDisassCmd.Flags().Int("dec-retries", 0, "Number of retries for LLM decompilation")
693- machoDisassCmd.Flags().Duration("dec-retry-backoff", 30*time.Second, "Backoff time between retries (e.g. '30s', '2m')")
694- machoDisassCmd.Flags().String("dec-theme", "nord", "Decompilation color theme (nord, github, etc)")
695- machoDisassCmd.RegisterFlagCompletionFunc("dec-theme", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
696- return styles.Names(), cobra.ShellCompDirectiveNoFileComp
697- })
698- machoDisassCmd.Flags().BoolP("json", "j", false, "Output as JSON")
699- machoDisassCmd.Flags().BoolP("quiet", "q", false, "Do NOT markup analysis (Faster)")
700- machoDisassCmd.Flags().Bool("force", false, "Continue to disassemble even if there are analysis errors")
701- // machoDisassCmd.Flags().StringP("input", "i", "", "Input function JSON file")
702- machoDisassCmd.Flags().StringP("fileset-entry", "t", "", "Which fileset entry to analyze")
703- machoDisassCmd.Flags().BoolP("all-fileset-entries", "z", false, "Parse all fileset entries")
704- machoDisassCmd.Flags().StringP("section", "x", "", "Disassemble an entire segment/section (i.e. __TEXT_EXEC.__text)")
705- machoDisassCmd.Flags().String("cache", "", "Path to .a2s addr to sym cache file (speeds up analysis)")
706- machoDisassCmd.MarkFlagsMutuallyExclusive("entry", "symbol", "vaddr", "off")
707-
708- viper.BindPFlag("macho.disass.arch", machoDisassCmd.Flags().Lookup("arch"))
709- viper.BindPFlag("macho.disass.entry", machoDisassCmd.Flags().Lookup("entry"))
710- viper.BindPFlag("macho.disass.symbol", machoDisassCmd.Flags().Lookup("symbol"))
711- viper.BindPFlag("macho.disass.vaddr", machoDisassCmd.Flags().Lookup("vaddr"))
712- viper.BindPFlag("macho.disass.off", machoDisassCmd.Flags().Lookup("off"))
713- viper.BindPFlag("macho.disass.count", machoDisassCmd.Flags().Lookup("count"))
714- viper.BindPFlag("macho.disass.demangle", machoDisassCmd.Flags().Lookup("demangle"))
715- viper.BindPFlag("macho.disass.dec", machoDisassCmd.Flags().Lookup("dec"))
716- viper.BindPFlag("macho.disass.dec-lang", machoDisassCmd.Flags().Lookup("dec-lang"))
717- viper.BindPFlag("macho.disass.dec-llm", machoDisassCmd.Flags().Lookup("dec-llm"))
718- viper.BindPFlag("macho.disass.dec-model", machoDisassCmd.Flags().Lookup("dec-model"))
719- viper.BindPFlag("macho.disass.dec-nocache", machoDisassCmd.Flags().Lookup("dec-nocache"))
720- viper.BindPFlag("macho.disass.dec-temp", machoDisassCmd.Flags().Lookup("dec-temp"))
721- viper.BindPFlag("macho.disass.dec-top-p", machoDisassCmd.Flags().Lookup("dec-top-p"))
722- viper.BindPFlag("macho.disass.dec-retries", machoDisassCmd.Flags().Lookup("dec-retries"))
723- viper.BindPFlag("macho.disass.dec-retry-backoff", machoDisassCmd.Flags().Lookup("dec-retry-backoff"))
724- viper.BindPFlag("macho.disass.dec-theme", machoDisassCmd.Flags().Lookup("dec-theme"))
725- viper.BindPFlag("macho.disass.json", machoDisassCmd.Flags().Lookup("json"))
726- viper.BindPFlag("macho.disass.quiet", machoDisassCmd.Flags().Lookup("quiet"))
727- viper.BindPFlag("macho.disass.force", machoDisassCmd.Flags().Lookup("force"))
728- // viper.BindPFlag("macho.disass.input", machoDisassCmd.Flags().Lookup("input"))
729- viper.BindPFlag("macho.disass.fileset-entry", machoDisassCmd.Flags().Lookup("fileset-entry"))
730- viper.BindPFlag("macho.disass.all-fileset-entries", machoDisassCmd.Flags().Lookup("all-fileset-entries"))
731- viper.BindPFlag("macho.disass.section", machoDisassCmd.Flags().Lookup("section"))
732- viper.BindPFlag("macho.disass.cache", machoDisassCmd.Flags().Lookup("cache"))
733-
734- machoDisassCmd.MarkZshCompPositionalArgumentFile(1)
735-}
736-
737-// machoDisassCmd represents the dis command
738-var machoDisassCmd = &cobra.Command{
739- Use: "disass <MACHO>",
740- Short: "Disassemble ARM64 MachO at symbol/vaddr",
741- Args: cobra.MinimumNArgs(1),
742- SilenceErrors: true,
743- RunE: func(cmd *cobra.Command, args []string) error {
744-
745- var err error
746- var m *macho.File
747- var ms []*macho.File
748- var middleAddr uint64
749- var engine *disass.MachoDisass
750-
751- // flags
752- selectedArch := viper.GetString("macho.disass.arch")
753- entryStart := viper.GetBool("macho.disass.entry")
754- symbolName := viper.GetString("macho.disass.symbol")
755- startAddr := viper.GetUint64("macho.disass.vaddr")
756- startOff := viper.GetUint64("macho.disass.off")
757- instructions := viper.GetUint64("macho.disass.count")
758- segmentSection := viper.GetString("macho.disass.section")
759-
760- decompile := viper.GetBool("macho.disass.dec")
761- demangleFlag := viper.GetBool("macho.disass.demangle")
762- asJSON := viper.GetBool("macho.disass.json")
763- quiet := viper.GetBool("macho.disass.quiet")
764-
765- tempFlagSet := dcmd.FlagWasProvided(cmd, "dec-temp", "macho.disass.dec-temp")
766- topPFlagSet := dcmd.FlagWasProvided(cmd, "dec-top-p", "macho.disass.dec-top-p")
767-
768- // funcFile := viper.GetString("macho.disass.input")
769- filesetEntry := viper.GetString("macho.disass.fileset-entry")
770- cacheFile := viper.GetString("macho.disass.cache")
771-
772- allFuncs := false
773-
774- // validate args
775- if len(symbolName) == 0 && startAddr == 0 && startOff == 0 && !entryStart {
776- allFuncs = true
777- // return fmt.Errorf("you must supply a --symbol OR --vaddr to disassemble")
778- }
779- if len(filesetEntry) > 0 && viper.GetBool("macho.disass.all-fileset-entries") {
780- return fmt.Errorf("you can only use --fileset-entry OR --all-fileset-entries (not both)")
781- } else if viper.GetBool("macho.disass.all-fileset-entries") && len(segmentSection) == 0 {
782- log.Warn("you probably want to add --section '__TEXT_EXEC.__text'; as the NEW MH_FILESET entries don't ALL have LC_FUNCTION_STARTS (iOS18 added LC_FUNCTION_STARTS to all KEXTs ❤️)")
783- }
784- if viper.GetString("macho.disass.dec-llm") != "" {
785- if !ai.IsValidProvider(viper.GetString("macho.disass.dec-llm")) {
786- return fmt.Errorf("invalid LLM provider '%s', must be one of: %s", viper.GetString("macho.disass.dec-llm"), strings.Join(ai.Providers, ", "))
787- }
788- }
789-
790- machoPath := filepath.Clean(args[0])
791-
792- if ok, err := magic.IsMachO(machoPath); !ok {
793- return fmt.Errorf("failed to detect file type: %v", err)
794- }
795-
796- mr, err := mcmd.OpenMachO(machoPath, selectedArch)
797- if err != nil {
798- return err
799- }
800- defer mr.Close()
801- m = mr.File
802-
803- if !strings.Contains(strings.ToLower(m.FileHeader.SubCPU.String(m.CPU)), "arm64") {
804- return fmt.Errorf("can only disassemble arm64 binaries")
805- }
806-
807- if m.FileTOC.FileHeader.Type == types.MH_FILESET &&
808- len(filesetEntry) == 0 && !viper.GetBool("macho.disass.all-fileset-entries") {
809- return fmt.Errorf("file is a MH_FILESET, you must supply a --fileset-entry OR --all-fileset-entries")
810- }
811-
812- if len(filesetEntry) > 0 {
813- if m.FileTOC.FileHeader.Type == types.MH_FILESET {
814- m, err = m.GetFileSetFileByName(filesetEntry)
815- if err != nil {
816- return fmt.Errorf("failed to parse entry %s: %v", filesetEntry, err)
817- }
818- } else {
819- log.Error("MachO type is not MH_FILESET (cannot use --fileset-entry)")
820- }
821- ms = append(ms, m)
822- } else if viper.GetBool("macho.disass.all-fileset-entries") {
823- for _, fe := range m.FileSets() {
824- mfe, err := m.GetFileSetFileByName(fe.EntryID)
825- if err != nil {
826- return fmt.Errorf("failed to parse entry %s: %v", filesetEntry, err)
827- }
828- ms = append(ms, mfe)
829- }
830- } else {
831- ms = append(ms, m)
832- }
833-
834- if err := ctrlc.Default.Run(context.Background(), func() error {
835- for _, m := range ms {
836- if entryStart {
837- if main := m.GetLoadsByName("LC_MAIN"); len(main) == 0 {
838- return fmt.Errorf("failed to find LC_MAIN in target when using --entry flag")
839- } else {
840- startAddr = main[0].(*macho.EntryPoint).EntryOffset + m.GetBaseAddress()
841- }
842- }
843- if startAddr == 0 && startOff != 0 {
844- if startAddr, err = m.GetVMAddress(startOff); err != nil {
845- return fmt.Errorf("failed to get vmaddr for file offset %#x: %v", startOff, err)
846- }
847- }
848-
849- if !quiet {
850- if len(cacheFile) == 0 {
851- cacheFile = machoPath + ".a2s"
852- }
853- }
854-
855- if allFuncs && len(segmentSection) == 0 {
856- for _, fn := range m.GetFunctions() {
857- data, err := m.GetFunctionData(fn)
858- if err != nil {
859- log.Errorf("failed to get data for function: %v", err)
860- continue
861- }
862-
863- engine = disass.NewMachoDisass(m, &disass.Config{
864- Data: data,
865- StartAddress: fn.StartAddr,
866- Middle: 0,
867- AsJSON: asJSON,
868- Demangle: demangleFlag,
869- Quiet: quiet,
870- Color: viper.GetBool("color") && !viper.GetBool("no-color") && !decompile,
871- })
872-
873- //***********************
874- //* First pass ANALYSIS *
875- //***********************
876- if !quiet {
877- if err := engine.OpenOrCreateSymMap(&cacheFile, machoPath); err != nil {
878- return fmt.Errorf("failed to open or create symbol map: %v", err)
879- }
880- if err := engine.Triage(); err != nil {
881- return fmt.Errorf("first pass triage failed: %v", err)
882- }
883- if engine.EmptySymMap() {
884- if err := engine.Analyze(); err != nil {
885- if !viper.GetBool("macho.disass.force") {
886- return fmt.Errorf("MachO analysis failed: %v (use --force to continue anyway)", err)
887- }
888- }
889- }
890- if err := engine.SaveAddrToSymMap(cacheFile); err != nil {
891- log.Errorf("failed to save symbol map: %v", err)
892- }
893- }
894- //***************
895- //* DISASSEMBLE *
896- //***************
897- asm := disass.Disassemble(engine)
898- if decompile && len(asm) > 0 {
899- decmp, err := dcmd.Decompile(asm, &dcmd.Config{
900- UUID: m.UUID().String(),
901- LLM: viper.GetString("macho.disass.dec-llm"),
902- Language: viper.GetString("macho.disass.dec-lang"),
903- Model: viper.GetString("macho.disass.dec-model"),
904- Temperature: viper.GetFloat64("macho.disass.dec-temp"),
905- TemperatureSet: tempFlagSet,
906- TopP: viper.GetFloat64("macho.disass.dec-top-p"),
907- TopPSet: topPFlagSet,
908- Stream: false,
909- DisableCache: viper.GetBool("macho.disass.dec-nocache"),
910- Verbose: viper.GetBool("verbose"),
911- Color: viper.GetBool("color") && !viper.GetBool("no-color"),
912- Theme: viper.GetString("macho.disass.dec-theme"),
913- MaxRetries: viper.GetInt("macho.disass.dec-retries"),
914- RetryBackoff: viper.GetDuration("macho.disass.dec-retry-backoff"),
915- })
916- if err != nil {
917- return fmt.Errorf("failed to decompile via llm: %v", err)
918- }
919- fmt.Println(decmp)
920- } else {
921- fmt.Println(asm)
922- }
923- }
924- } else {
925- if len(symbolName) > 0 {
926- startAddr, err = m.FindSymbolAddress(symbolName)
927- if err != nil {
928- return err
929- }
930- } else if len(segmentSection) > 0 {
931- parts := strings.Split(segmentSection, ".")
932- if len(parts) != 2 {
933- return fmt.Errorf("invalid --section format, must be segment.section")
934- }
935- if sec := m.Section(parts[0], parts[1]); sec != nil {
936- startAddr = sec.Addr
937- instructions = sec.Size / 4
938- } else {
939- return fmt.Errorf("failed to find section %s", segmentSection)
940- }
941- }
942- // startAddr > 0 TODO: support slides
943- // if slide > 0 {
944- // startAddr = startAddr - slide
945- // }
946-
947- /*
948- * Read in data to disassemble
949- */
950- var data []byte
951- if instructions > 0 {
952- off, err := m.GetOffset(startAddr)
953- if err != nil {
954- return err
955- }
956- data = make([]byte, instructions*4)
957- if _, err := m.ReadAt(data, int64(off)); err != nil {
958- return err
959- }
960- } else {
961- if fn, err := m.GetFunctionForVMAddr(startAddr); err == nil {
962- soff, err := m.GetOffset(fn.StartAddr)
963- if err != nil {
964- return err
965- }
966- data = make([]byte, uint64(fn.EndAddr-fn.StartAddr))
967- if _, err := m.ReadAt(data, int64(soff)); err != nil {
968- return err
969- }
970- if startAddr != fn.StartAddr {
971- middleAddr = startAddr
972- startAddr = fn.StartAddr
973- }
974- } else {
975- log.Warnf("disassembling 100 instructions at %#x", startAddr)
976- instructions = 100
977- off, err := m.GetOffset(startAddr)
978- if err != nil {
979- return err
980- }
981- data = make([]byte, instructions*4)
982- if _, err := m.ReadAt(data, int64(off)); err != nil {
983- return err
984- }
985- }
986- }
987- if len(data) == 0 {
988- log.Fatal("failed to disassemble")
989- }
990-
991- engine = disass.NewMachoDisass(m, &disass.Config{
992- Data: data,
993- StartAddress: startAddr,
994- Middle: middleAddr,
995- AsJSON: asJSON,
996- Demangle: demangleFlag,
997- Quiet: quiet,
998- Color: viper.GetBool("color") && !viper.GetBool("no-color") && !decompile,
999- })
1000-
1001- //***********************
1002- //* First pass ANALYSIS *
1003- //***********************
1004- if !quiet {
1005- if err := engine.OpenOrCreateSymMap(&cacheFile, machoPath); err != nil {
1006- return fmt.Errorf("failed to open or create symbol map: %v", err)
1007- }
1008- if err := engine.Triage(); err != nil {
1009- return fmt.Errorf("first pass triage failed: %v", err)
1010- }
1011- // if engine.EmptySymMap() { FIXME: this is fast enough where we don't need to do this optimization
1012- if err := engine.Analyze(); err != nil {
1013- if !viper.GetBool("macho.disass.force") {
1014- return fmt.Errorf("MachO analysis failed: %v (use --force to continue anyway)", err)
1015- }
1016- }
1017- // }
1018- if entryStart {
1019- engine.SetStartSym(startAddr)
1020- }
1021- if err := engine.SaveAddrToSymMap(cacheFile); err != nil {
1022- log.Errorf("failed to save symbol map: %v", err)
1023- }
1024- }
1025- //***************
1026- //* DISASSEMBLE *
1027- //***************
1028- asm := disass.Disassemble(engine)
1029- if decompile && len(asm) > 0 {
1030- decmp, err := dcmd.Decompile(asm, &dcmd.Config{
1031- UUID: m.UUID().String(),
1032- LLM: viper.GetString("macho.disass.dec-llm"),
1033- Language: viper.GetString("macho.disass.dec-lang"),
1034- Model: viper.GetString("macho.disass.dec-model"),
1035- Temperature: viper.GetFloat64("macho.disass.dec-temp"),
1036- TemperatureSet: tempFlagSet,
1037- TopP: viper.GetFloat64("macho.disass.dec-top-p"),
1038- TopPSet: topPFlagSet,
1039- Stream: false,
1040- DisableCache: viper.GetBool("macho.disass.dec-nocache"),
1041- Verbose: viper.GetBool("verbose"),
1042- Color: viper.GetBool("color") && !viper.GetBool("no-color"),
1043- Theme: viper.GetString("macho.disass.dec-theme"),
1044- MaxRetries: viper.GetInt("macho.disass.dec-retries"),
1045- RetryBackoff: viper.GetDuration("macho.disass.dec-retry-backoff"),
1046- })
1047- if err != nil {
1048- return fmt.Errorf("failed to decompile via llm: %v", err)
1049- }
1050- fmt.Println(decmp)
1051- } else {
1052- fmt.Println(asm)
1053- }
1054- }
1055- }
1056- return nil
1057- }); err != nil {
1058- if errors.As(err, &ctrlc.ErrorCtrlC{}) {
1059- if !quiet {
1060- if err := engine.SaveAddrToSymMap(cacheFile); err != nil {
1061- log.Errorf("failed to save symbol map: %v", err)
1062- }
1063- }
1064- log.Warn("exiting...")
1065- } else {
1066- return err
1067- }
1068- }
1069- return nil
1070- },
1071-}
1072diff --git a/internal/ai/acp/acp.go b/internal/ai/acp/acp.go
1073deleted file mode 100644
1074index 7a6549cf7..000000000
1075--- a/internal/ai/acp/acp.go
1076+++ /dev/null
1077@@ -1,579 +0,0 @@
1078-package acp
1079-
1080-import (
1081- "context"
1082- "fmt"
1083- "io"
1084- "log/slog"
1085- "os"
1086- "os/exec"
1087- "path/filepath"
1088- "strings"
1089- "sync"
1090- "time"
1091-
1092- "github.com/apex/log"
1093- acp "github.com/coder/acp-go-sdk"
1094-
1095- "github.com/blacktop/ipsw/internal/ai/utils"
1096-)
1097-
1098-const maxACPStderrBytes = 4096
1099-
1100-type Config struct {
1101- Prompt string `json:"prompt"`
1102- Model string `json:"model"`
1103- Temperature float64 `json:"temperature"`
1104- TopP float64 `json:"top_p"`
1105- Stream bool `json:"stream"`
1106-
1107- Command string `json:"-"`
1108- Args []string `json:"-"`
1109- Env []string `json:"-"`
1110- Verbose bool `json:"-"`
1111-}
1112-
1113-type ACP struct {
1114- ctx context.Context
1115- conf *Config
1116- models map[string]string
1117-}
1118-
1119-var discardACPLogger = slog.New(slog.NewTextHandler(io.Discard, nil))
1120-
1121-func New(ctx context.Context, conf *Config) (*ACP, error) {
1122- if conf == nil {
1123- return nil, fmt.Errorf("acp: config is nil")
1124- }
1125- if strings.TrimSpace(conf.Command) == "" {
1126- return nil, fmt.Errorf("acp: command is required")
1127- }
1128- return &ACP{ctx: ctx, conf: conf}, nil
1129-}
1130-
1131-func (c *ACP) Models() (map[string]string, error) {
1132- if len(c.models) > 0 {
1133- return c.models, nil
1134- }
1135-
1136- // If the user pre-selected a model, treat it as the only selectable option.
1137- if strings.TrimSpace(c.conf.Model) != "" {
1138- c.models = map[string]string{c.conf.Model: c.conf.Model}
1139- return c.models, nil
1140- }
1141-
1142- // Attempt to query supported models via ACP. The protocol exposes these as a
1143- // "model"-category session config option returned from `session/new`.
1144- models, err := c.fetchModelsViaACP()
1145- if err == nil && len(models) > 0 {
1146- c.models = models
1147- return c.models, nil
1148- }
1149- if err != nil && c.conf.Verbose {
1150- log.Debugf("acp: failed to fetch models (falling back to default): %v", err)
1151- }
1152-
1153- // Fallback: the rest of the codebase expects at least one selectable model.
1154- c.models = map[string]string{"default": "default"}
1155- return c.models, nil
1156-}
1157-
1158-func (c *ACP) fetchModelsViaACP() (map[string]string, error) {
1159- ctx, cancel := context.WithTimeout(c.ctx, 45*time.Second)
1160- defer cancel()
1161-
1162- cmd := exec.CommandContext(ctx, c.conf.Command, c.conf.Args...)
1163- cmd.Env = append(os.Environ(), c.conf.Env...)
1164-
1165- stdin, err := cmd.StdinPipe()
1166- if err != nil {
1167- return nil, fmt.Errorf("acp: failed to get stdin pipe: %w", err)
1168- }
1169- stdout, err := cmd.StdoutPipe()
1170- if err != nil {
1171- return nil, fmt.Errorf("acp: failed to get stdout pipe: %w", err)
1172- }
1173- stderr, err := cmd.StderrPipe()
1174- if err != nil {
1175- return nil, fmt.Errorf("acp: failed to get stderr pipe: %w", err)
1176- }
1177-
1178- if err := cmd.Start(); err != nil {
1179- return nil, fmt.Errorf("acp: failed to start agent command '%s': %w", c.conf.Command, err)
1180- }
1181- stderrCapture := copyACPStderr(stderr, c.conf.Verbose)
1182- defer func() {
1183- _ = stdin.Close()
1184- _ = stdout.Close()
1185- _ = stderr.Close()
1186- if cmd.Process != nil {
1187- _ = cmd.Process.Kill()
1188- }
1189- _ = cmd.Wait()
1190- }()
1191-
1192- clientConn, err := newClientConnection(stdin, stdout, c.conf.Verbose)
1193- if err != nil {
1194- return nil, err
1195- }
1196- if err := initializeClient(ctx, clientConn.conn); err != nil {
1197- return nil, stderrCapture.wrap(err)
1198- }
1199- sess, err := clientConn.conn.NewSession(ctx, acp.NewSessionRequest{
1200- Cwd: clientConn.cwd,
1201- McpServers: []acp.McpServer{},
1202- })
1203- if err != nil {
1204- return nil, stderrCapture.wrap(fmt.Errorf("acp: newSession failed: %w", err))
1205- }
1206-
1207- models := make(map[string]string)
1208- sel := modelConfigOption(sess.ConfigOptions)
1209- if sel == nil {
1210- return models, nil
1211- }
1212- for _, opt := range selectOptionValues(sel) {
1213- id := strings.TrimSpace(string(opt.Value))
1214- if id == "" {
1215- continue
1216- }
1217- key := strings.TrimSpace(opt.Name)
1218- if key == "" {
1219- key = id
1220- }
1221- if _, exists := models[key]; exists {
1222- key = fmt.Sprintf("%s (%s)", key, id)
1223- }
1224- models[key] = id
1225- }
1226-
1227- return models, nil
1228-}
1229-
1230-// modelConfigOption returns the model-selection config option the agent
1231-// advertised in its session configuration, or nil if none is exposed. The ACP
1232-// protocol surfaces model choices as a "select" session config option tagged
1233-// with the "model" category.
1234-func modelConfigOption(opts []acp.SessionConfigOption) *acp.SessionConfigOptionSelect {
1235- for i := range opts {
1236- sel := opts[i].Select
1237- if sel == nil || sel.Category == nil {
1238- continue
1239- }
1240- if *sel.Category == acp.SessionConfigOptionCategoryModel {
1241- return sel
1242- }
1243- }
1244- return nil
1245-}
1246-
1247-// selectOptionValues flattens a select config option's grouped and ungrouped
1248-// values into a single slice.
1249-func selectOptionValues(sel *acp.SessionConfigOptionSelect) []acp.SessionConfigSelectOption {
1250- var out []acp.SessionConfigSelectOption
1251- if sel.Options.Ungrouped != nil {
1252- out = append(out, (*sel.Options.Ungrouped)...)
1253- }
1254- if sel.Options.Grouped != nil {
1255- for _, group := range *sel.Options.Grouped {
1256- out = append(out, group.Options...)
1257- }
1258- }
1259- return out
1260-}
1261-
1262-func (c *ACP) SetModel(model string) error {
1263- model = strings.TrimSpace(model)
1264- if model == "" {
1265- return fmt.Errorf("no model specified")
1266- }
1267- if c.models == nil {
1268- c.models = make(map[string]string)
1269- }
1270- if modelID := strings.TrimSpace(c.models[model]); modelID != "" {
1271- c.models[modelID] = modelID
1272- c.conf.Model = modelID
1273- return nil
1274- }
1275- // ACP agents/adapters may accept arbitrary model IDs without a prior list.
1276- c.models[model] = model
1277- c.conf.Model = model
1278- return nil
1279-}
1280-
1281-func (c *ACP) SetModels(models map[string]string) (map[string]string, error) {
1282- c.models = models
1283- return c.models, nil
1284-}
1285-
1286-func (c *ACP) Verify() error {
1287- if strings.TrimSpace(c.conf.Model) == "" {
1288- return fmt.Errorf("no model specified")
1289- }
1290- if c.models == nil {
1291- c.models = make(map[string]string)
1292- }
1293- if _, ok := c.models[c.conf.Model]; !ok {
1294- c.models[c.conf.Model] = c.conf.Model
1295- }
1296- if c.models[c.conf.Model] == "" {
1297- return fmt.Errorf("model '%s' has empty ID", c.conf.Model)
1298- }
1299- return nil
1300-}
1301-
1302-type collectingClient struct {
1303- mu sync.Mutex
1304- b strings.Builder
1305-
1306- cwd string
1307-}
1308-
1309-var _ acp.Client = (*collectingClient)(nil)
1310-
1311-func (c *collectingClient) RequestPermission(ctx context.Context, p acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) {
1312- if id, ok := autoApprovedPermissionOption(p); ok {
1313- return acp.RequestPermissionResponse{Outcome: acp.NewRequestPermissionOutcomeSelected(id)}, nil
1314- }
1315- return acp.RequestPermissionResponse{Outcome: acp.RequestPermissionOutcome{Cancelled: &acp.RequestPermissionOutcomeCancelled{}}}, nil
1316-}
1317-
1318-func (c *collectingClient) SessionUpdate(ctx context.Context, n acp.SessionNotification) error {
1319- if n.Update.AgentMessageChunk != nil {
1320- content := n.Update.AgentMessageChunk.Content
1321- if content.Text != nil {
1322- c.mu.Lock()
1323- c.b.WriteString(content.Text.Text)
1324- c.mu.Unlock()
1325- }
1326- }
1327- return nil
1328-}
1329-
1330-func (c *collectingClient) String() string {
1331- c.mu.Lock()
1332- defer c.mu.Unlock()
1333- return c.b.String()
1334-}
1335-
1336-func (c *collectingClient) ReadTextFile(ctx context.Context, req acp.ReadTextFileRequest) (acp.ReadTextFileResponse, error) {
1337- if err := ctx.Err(); err != nil {
1338- return acp.ReadTextFileResponse{}, err
1339- }
1340- path, err := c.resolveReadPath(req.Path)
1341- if err != nil {
1342- return acp.ReadTextFileResponse{}, err
1343- }
1344- b, err := os.ReadFile(path)
1345- if err != nil {
1346- return acp.ReadTextFileResponse{}, fmt.Errorf("acp client: read %s: %w", req.Path, err)
1347- }
1348- return acp.ReadTextFileResponse{Content: readTextWindow(string(b), req.Line, req.Limit)}, nil
1349-}
1350-
1351-func (c *collectingClient) WriteTextFile(ctx context.Context, _ acp.WriteTextFileRequest) (acp.WriteTextFileResponse, error) {
1352- return acp.WriteTextFileResponse{}, fmt.Errorf("acp client: writeTextFile denied")
1353-}
1354-
1355-func (c *collectingClient) CreateTerminal(ctx context.Context, _ acp.CreateTerminalRequest) (acp.CreateTerminalResponse, error) {
1356- return acp.CreateTerminalResponse{}, fmt.Errorf("acp client: terminal not supported")
1357-}
1358-
1359-func (c *collectingClient) KillTerminal(ctx context.Context, _ acp.KillTerminalRequest) (acp.KillTerminalResponse, error) {
1360- return acp.KillTerminalResponse{}, fmt.Errorf("acp client: terminal not supported")
1361-}
1362-
1363-func (c *collectingClient) ReleaseTerminal(ctx context.Context, _ acp.ReleaseTerminalRequest) (acp.ReleaseTerminalResponse, error) {
1364- return acp.ReleaseTerminalResponse{}, fmt.Errorf("acp client: terminal not supported")
1365-}
1366-
1367-func (c *collectingClient) TerminalOutput(ctx context.Context, _ acp.TerminalOutputRequest) (acp.TerminalOutputResponse, error) {
1368- return acp.TerminalOutputResponse{}, fmt.Errorf("acp client: terminal not supported")
1369-}
1370-
1371-func (c *collectingClient) WaitForTerminalExit(ctx context.Context, _ acp.WaitForTerminalExitRequest) (acp.WaitForTerminalExitResponse, error) {
1372- return acp.WaitForTerminalExitResponse{}, fmt.Errorf("acp client: terminal not supported")
1373-}
1374-
1375-type acpStderrCapture struct {
1376- tail *lockedTailBuffer
1377- done chan struct{}
1378-}
1379-
1380-type lockedTailBuffer struct {
1381- mu sync.Mutex
1382- limit int
1383- buf []byte
1384-}
1385-
1386-func newLockedTailBuffer(limit int) *lockedTailBuffer {
1387- return &lockedTailBuffer{limit: limit}
1388-}
1389-
1390-func (b *lockedTailBuffer) Write(p []byte) (int, error) {
1391- b.mu.Lock()
1392- defer b.mu.Unlock()
1393- b.buf = append(b.buf, p...)
1394- if len(b.buf) > b.limit {
1395- b.buf = b.buf[len(b.buf)-b.limit:]
1396- }
1397- return len(p), nil
1398-}
1399-
1400-func (b *lockedTailBuffer) String() string {
1401- b.mu.Lock()
1402- defer b.mu.Unlock()
1403- return strings.TrimSpace(string(b.buf))
1404-}
1405-
1406-func copyACPStderr(stderr io.Reader, verbose bool) *acpStderrCapture {
1407- tail := newLockedTailBuffer(maxACPStderrBytes)
1408- capture := &acpStderrCapture{
1409- tail: tail,
1410- done: make(chan struct{}),
1411- }
1412- dst := io.Writer(tail)
1413- if verbose {
1414- dst = io.MultiWriter(os.Stderr, tail)
1415- }
1416- go func() {
1417- defer close(capture.done)
1418- _, _ = io.Copy(dst, stderr)
1419- }()
1420- return capture
1421-}
1422-
1423-func (c *acpStderrCapture) String() string {
1424- if c == nil || c.tail == nil {
1425- return ""
1426- }
1427- select {
1428- case <-c.done:
1429- case <-time.After(100 * time.Millisecond):
1430- }
1431- return c.tail.String()
1432-}
1433-
1434-func (c *acpStderrCapture) wrap(err error) error {
1435- if c == nil {
1436- return err
1437- }
1438- if stderr := c.String(); stderr != "" {
1439- return fmt.Errorf("%w\nagent stderr:\n%s", err, stderr)
1440- }
1441- return err
1442-}
1443-
1444-type clientConnection struct {
1445- cwd string
1446- client *collectingClient
1447- conn *acp.ClientSideConnection
1448-}
1449-
1450-func newClientConnection(stdin io.Writer, stdout io.Reader, verbose bool) (*clientConnection, error) {
1451- cwd, err := os.Getwd()
1452- if err != nil {
1453- return nil, fmt.Errorf("acp: failed to get current working directory: %w", err)
1454- }
1455- client := &collectingClient{cwd: cwd}
1456- conn := acp.NewClientSideConnection(client, stdin, stdout)
1457- if !verbose {
1458- conn.SetLogger(discardACPLogger)
1459- }
1460- return &clientConnection{cwd: cwd, client: client, conn: conn}, nil
1461-}
1462-
1463-func initializeClient(ctx context.Context, conn *acp.ClientSideConnection) error {
1464- _, err := conn.Initialize(ctx, acp.InitializeRequest{
1465- ProtocolVersion: acp.ProtocolVersionNumber,
1466- ClientCapabilities: acp.ClientCapabilities{
1467- // NOTE: Some ACP agents (notably gemini --experimental-acp) validate that
1468- // fs.readTextFile and fs.writeTextFile are present in the initialize payload.
1469- // The SDK uses `omitempty` on these booleans, so `false` would be omitted and
1470- // the agent rejects the request. We support readTextFile within the
1471- // session cwd and keep writeTextFile denied in the handler implementation.
1472- Fs: acp.FileSystemCapabilities{ReadTextFile: true, WriteTextFile: true},
1473- Terminal: false,
1474- },
1475- })
1476- if err != nil {
1477- return fmt.Errorf("acp: initialize failed: %w", err)
1478- }
1479- return nil
1480-}
1481-
1482-func autoApprovedPermissionOption(req acp.RequestPermissionRequest) (acp.PermissionOptionId, bool) {
1483- if req.ToolCall.Kind == nil {
1484- return "", false
1485- }
1486- switch *req.ToolCall.Kind {
1487- case acp.ToolKindRead, acp.ToolKindSearch, acp.ToolKindThink:
1488- default:
1489- return "", false
1490- }
1491- for _, opt := range req.Options {
1492- if opt.Kind == acp.PermissionOptionKindAllowOnce {
1493- return opt.OptionId, true
1494- }
1495- }
1496- for _, opt := range req.Options {
1497- if opt.Kind == acp.PermissionOptionKindAllowAlways {
1498- return opt.OptionId, true
1499- }
1500- }
1501- return "", false
1502-}
1503-
1504-func (c *collectingClient) resolveReadPath(path string) (string, error) {
1505- if !filepath.IsAbs(path) {
1506- return "", fmt.Errorf("acp client: readTextFile path must be absolute: %s", path)
1507- }
1508- root, err := filepath.Abs(c.cwd)
1509- if err != nil {
1510- return "", fmt.Errorf("acp client: resolve cwd %s: %w", c.cwd, err)
1511- }
1512- root, err = filepath.EvalSymlinks(root)
1513- if err != nil {
1514- return "", fmt.Errorf("acp client: resolve cwd %s: %w", c.cwd, err)
1515- }
1516- target, err := filepath.EvalSymlinks(filepath.Clean(path))
1517- if err != nil {
1518- return "", fmt.Errorf("acp client: resolve read path %s: %w", path, err)
1519- }
1520- rel, err := filepath.Rel(root, target)
1521- if err != nil {
1522- return "", fmt.Errorf("acp client: resolve read path %s relative to %s: %w", path, root, err)
1523- }
1524- if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
1525- return "", fmt.Errorf("acp client: readTextFile outside session cwd denied: %s", path)
1526- }
1527- return target, nil
1528-}
1529-
1530-func readTextWindow(content string, line, limit *int) string {
1531- if line == nil && limit == nil {
1532- return content
1533- }
1534- lines := strings.Split(content, "\n")
1535- start := 0
1536- if line != nil && *line > 1 {
1537- start = min(*line-1, len(lines))
1538- }
1539- end := len(lines)
1540- if limit != nil {
1541- if *limit <= 0 {
1542- end = start
1543- } else if start+*limit < end {
1544- end = start + *limit
1545- }
1546- }
1547- return strings.Join(lines[start:end], "\n")
1548-}
1549-
1550-func promptStopError(reason acp.StopReason, resp string) error {
1551- resp = strings.TrimSpace(utils.Clean(resp))
1552- if resp == "" {
1553- return fmt.Errorf("acp: prompt stopped with %s", reason)
1554- }
1555- const maxStopReasonSnippet = 1024
1556- if len(resp) > maxStopReasonSnippet {
1557- resp = resp[:maxStopReasonSnippet] + "... (truncated)"
1558- }
1559- return fmt.Errorf("acp: prompt stopped with %s: %s", reason, resp)
1560-}
1561-
1562-func (c *ACP) Chat() (string, error) {
1563- if err := c.Verify(); err != nil {
1564- return "", fmt.Errorf("invalid model configuration: %w", err)
1565- }
1566-
1567- ctx, cancel := context.WithTimeout(c.ctx, 10*time.Minute)
1568- defer cancel()
1569-
1570- cmd := exec.CommandContext(ctx, c.conf.Command, c.conf.Args...)
1571- cmd.Env = append(os.Environ(), c.conf.Env...)
1572-
1573- stdin, err := cmd.StdinPipe()
1574- if err != nil {
1575- return "", fmt.Errorf("acp: failed to get stdin pipe: %w", err)
1576- }
1577- stdout, err := cmd.StdoutPipe()
1578- if err != nil {
1579- return "", fmt.Errorf("acp: failed to get stdout pipe: %w", err)
1580- }
1581- stderr, err := cmd.StderrPipe()
1582- if err != nil {
1583- return "", fmt.Errorf("acp: failed to get stderr pipe: %w", err)
1584- }
1585-
1586- if err := cmd.Start(); err != nil {
1587- return "", fmt.Errorf("acp: failed to start agent command '%s': %w", c.conf.Command, err)
1588- }
1589- stderrCapture := copyACPStderr(stderr, c.conf.Verbose)
1590- defer func() {
1591- _ = stdin.Close()
1592- _ = stdout.Close()
1593- _ = stderr.Close()
1594- if cmd.Process != nil {
1595- _ = cmd.Process.Kill()
1596- }
1597- _ = cmd.Wait()
1598- }()
1599-
1600- clientConn, err := newClientConnection(stdin, stdout, c.conf.Verbose)
1601- if err != nil {
1602- return "", err
1603- }
1604- if err := initializeClient(ctx, clientConn.conn); err != nil {
1605- return "", stderrCapture.wrap(err)
1606- }
1607-
1608- sess, err := clientConn.conn.NewSession(ctx, acp.NewSessionRequest{
1609- Cwd: clientConn.cwd,
1610- McpServers: []acp.McpServer{},
1611- })
1612- if err != nil {
1613- return "", stderrCapture.wrap(fmt.Errorf("acp: newSession failed: %w", err))
1614- }
1615-
1616- // Best-effort: attempt to set the model if the agent advertises a model
1617- // configuration option (ACP session config option, category "model").
1618- if m := strings.TrimSpace(c.conf.Model); m != "" && m != "default" {
1619- if sel := modelConfigOption(sess.ConfigOptions); sel != nil {
1620- setModelReq := acp.SetSessionConfigOptionRequest{
1621- ValueId: &acp.SetSessionConfigOptionValueId{
1622- ConfigId: sel.Id,
1623- SessionId: sess.SessionId,
1624- Value: acp.SessionConfigValueId(m),
1625- },
1626- }
1627- if _, setErr := clientConn.conn.SetSessionConfigOption(ctx, setModelReq); setErr != nil {
1628- if c.conf.Verbose {
1629- log.Debugf("acp: SetSessionConfigOption(model) failed (ignored): %v", setErr)
1630- }
1631- }
1632- }
1633- }
1634-
1635- promptResp, err := clientConn.conn.Prompt(ctx, acp.PromptRequest{
1636- SessionId: sess.SessionId,
1637- Prompt: []acp.ContentBlock{acp.TextBlock(c.conf.Prompt)},
1638- })
1639- if err != nil {
1640- return "", stderrCapture.wrap(fmt.Errorf("acp: prompt failed: %w", err))
1641- }
1642-
1643- resp := strings.TrimSpace(clientConn.client.String())
1644- if promptResp.StopReason != "" && promptResp.StopReason != acp.StopReasonEndTurn {
1645- return "", promptStopError(promptResp.StopReason, resp)
1646- }
1647- if resp == "" {
1648- return "", fmt.Errorf("no content returned from agent")
1649- }
1650-
1651- return utils.Clean(resp), nil
1652-}
1653-
1654-func (c *ACP) Close() error {
1655- return nil
1656-}
1657diff --git a/internal/ai/acp/acp_test.go b/internal/ai/acp/acp_test.go
1658deleted file mode 100644
1659index 08b57ee7e..000000000
1660--- a/internal/ai/acp/acp_test.go
1661+++ /dev/null
1662@@ -1,403 +0,0 @@
1663-package acp
1664-
1665-import (
1666- "context"
1667- "fmt"
1668- "os"
1669- "path/filepath"
1670- "strings"
1671- "testing"
1672- "time"
1673-
1674- acpsdk "github.com/coder/acp-go-sdk"
1675-)
1676-
1677-const (
1678- testACPAgentModeEnv = "IPSW_TEST_ACP_AGENT_MODE"
1679- testACPReadPathEnv = "IPSW_TEST_ACP_READ_PATH"
1680-)
1681-
1682-func TestACPModelsFromAgent(t *testing.T) {
1683- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1684- defer cancel()
1685-
1686- client, err := New(ctx, &Config{
1687- Prompt: "decompile this",
1688- Command: os.Args[0],
1689- Args: []string{"-test.run=TestACPAgentHelperProcess"},
1690- Env: []string{testACPAgentModeEnv + "=ok"},
1691- })
1692- if err != nil {
1693- t.Fatalf("New() error = %v", err)
1694- }
1695- models, err := client.Models()
1696- if err != nil {
1697- t.Fatalf("Models() error = %v", err)
1698- }
1699- if got := models["Test Model"]; got != "test-model" {
1700- t.Fatalf("Models()[Test Model] = %q, want test-model", got)
1701- }
1702-}
1703-
1704-func TestACPChatAllowsReadOnlyFileContext(t *testing.T) {
1705- dir := t.TempDir()
1706- readPath := filepath.Join(dir, "context.txt")
1707- if err := os.WriteFile(readPath, []byte("alpha\nbravo\ncharlie\n"), 0o644); err != nil {
1708- t.Fatalf("write test context: %v", err)
1709- }
1710- chdir(t, dir)
1711-
1712- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1713- defer cancel()
1714-
1715- client := newTestACP(t, ctx, "read", readPath)
1716- resp, err := client.Chat()
1717- if err != nil {
1718- t.Fatalf("Chat() error = %v", err)
1719- }
1720- if resp != "read:bravo" {
1721- t.Fatalf("Chat() = %q, want read:bravo", resp)
1722- }
1723-}
1724-
1725-func TestACPChatReportsNonEndTurnStopReason(t *testing.T) {
1726- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1727- defer cancel()
1728-
1729- client := newTestACP(t, ctx, "cancelled", "")
1730- _, err := client.Chat()
1731- if err == nil {
1732- t.Fatal("Chat() error = nil, want stop reason error")
1733- }
1734- if !strings.Contains(err.Error(), "prompt stopped with cancelled") {
1735- t.Fatalf("Chat() error = %q, want cancelled stop reason", err)
1736- }
1737-}
1738-
1739-func TestACPChatIncludesAgentStderrOnInitializeFailure(t *testing.T) {
1740- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1741- defer cancel()
1742-
1743- client := newTestACP(t, ctx, "stderr-exit", "")
1744- _, err := client.Chat()
1745- if err == nil {
1746- t.Fatal("Chat() error = nil, want initialize failure")
1747- }
1748- if !strings.Contains(err.Error(), "agent stderr:") {
1749- t.Fatalf("Chat() error = %q, want agent stderr section", err)
1750- }
1751- if !strings.Contains(err.Error(), "test acp adapter failed before initialize") {
1752- t.Fatalf("Chat() error = %q, want adapter stderr", err)
1753- }
1754-}
1755-
1756-func TestACPSetModelResolvesDisplayNameToModelID(t *testing.T) {
1757- client, err := New(context.Background(), &Config{Command: "test-agent"})
1758- if err != nil {
1759- t.Fatalf("New() error = %v", err)
1760- }
1761- if _, err := client.SetModels(map[string]string{"Default (recommended)": "default"}); err != nil {
1762- t.Fatalf("SetModels() error = %v", err)
1763- }
1764-
1765- if err := client.SetModel("Default (recommended)"); err != nil {
1766- t.Fatalf("SetModel() error = %v", err)
1767- }
1768- if client.conf.Model != "default" {
1769- t.Fatalf("conf.Model = %q, want default", client.conf.Model)
1770- }
1771- if got := client.models["default"]; got != "default" {
1772- t.Fatalf("models[default] = %q, want default", got)
1773- }
1774-}
1775-
1776-func TestACPSetModelAllowsArbitraryModelID(t *testing.T) {
1777- client, err := New(context.Background(), &Config{Command: "test-agent"})
1778- if err != nil {
1779- t.Fatalf("New() error = %v", err)
1780- }
1781-
1782- if err := client.SetModel("custom-model"); err != nil {
1783- t.Fatalf("SetModel() error = %v", err)
1784- }
1785- if client.conf.Model != "custom-model" {
1786- t.Fatalf("conf.Model = %q, want custom-model", client.conf.Model)
1787- }
1788- if got := client.models["custom-model"]; got != "custom-model" {
1789- t.Fatalf("models[custom-model] = %q, want custom-model", got)
1790- }
1791-}
1792-
1793-func TestLockedTailBufferKeepsBoundedStderrTail(t *testing.T) {
1794- buf := newLockedTailBuffer(5)
1795- if _, err := buf.Write([]byte("hello")); err != nil {
1796- t.Fatalf("Write() error = %v", err)
1797- }
1798- if _, err := buf.Write([]byte(" world")); err != nil {
1799- t.Fatalf("Write() error = %v", err)
1800- }
1801- if got := buf.String(); got != "world" {
1802- t.Fatalf("String() = %q, want world", got)
1803- }
1804-}
1805-
1806-func TestACPStderrCaptureWaitsForCopyToFinish(t *testing.T) {
1807- buf := newLockedTailBuffer(32)
1808- capture := &acpStderrCapture{
1809- tail: buf,
1810- done: make(chan struct{}),
1811- }
1812- go func() {
1813- time.Sleep(10 * time.Millisecond)
1814- _, _ = buf.Write([]byte("delayed stderr"))
1815- close(capture.done)
1816- }()
1817-
1818- if got := capture.String(); got != "delayed stderr" {
1819- t.Fatalf("String() = %q, want delayed stderr", got)
1820- }
1821-}
1822-
1823-func TestCollectingClientReadTextFileRestrictsCWD(t *testing.T) {
1824- dir := t.TempDir()
1825- inside := filepath.Join(dir, "inside.txt")
1826- if err := os.WriteFile(inside, []byte("one\ntwo\nthree"), 0o644); err != nil {
1827- t.Fatalf("write inside file: %v", err)
1828- }
1829- outsideDir := t.TempDir()
1830- outside := filepath.Join(outsideDir, "outside.txt")
1831- if err := os.WriteFile(outside, []byte("secret"), 0o644); err != nil {
1832- t.Fatalf("write outside file: %v", err)
1833- }
1834-
1835- client := &collectingClient{cwd: dir}
1836- line, limit := 2, 1
1837- resp, err := client.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{
1838- Path: inside,
1839- Line: &line,
1840- Limit: &limit,
1841- })
1842- if err != nil {
1843- t.Fatalf("ReadTextFile(inside) error = %v", err)
1844- }
1845- if resp.Content != "two" {
1846- t.Fatalf("ReadTextFile(inside) = %q, want two", resp.Content)
1847- }
1848-
1849- if _, err := client.ReadTextFile(context.Background(), acpsdk.ReadTextFileRequest{Path: outside}); err == nil {
1850- t.Fatal("ReadTextFile(outside) error = nil, want denial")
1851- }
1852-}
1853-
1854-func TestCollectingClientPermissionPolicy(t *testing.T) {
1855- client := &collectingClient{}
1856- tests := []struct {
1857- name string
1858- kind acpsdk.ToolKind
1859- wantAllow bool
1860- }{
1861- {name: "read", kind: acpsdk.ToolKindRead, wantAllow: true},
1862- {name: "edit", kind: acpsdk.ToolKindEdit},
1863- }
1864-
1865- for _, tt := range tests {
1866- t.Run(tt.name, func(t *testing.T) {
1867- kind := tt.kind
1868- resp, err := client.RequestPermission(context.Background(), acpsdk.RequestPermissionRequest{
1869- ToolCall: acpsdk.ToolCallUpdate{ToolCallId: acpsdk.ToolCallId(tt.name), Kind: &kind},
1870- Options: []acpsdk.PermissionOption{
1871- {Kind: acpsdk.PermissionOptionKindAllowOnce, Name: "Allow", OptionId: acpsdk.PermissionOptionId("allow")},
1872- {Kind: acpsdk.PermissionOptionKindRejectOnce, Name: "Reject", OptionId: acpsdk.PermissionOptionId("reject")},
1873- },
1874- })
1875- if err != nil {
1876- t.Fatalf("RequestPermission(%s) error = %v", tt.name, err)
1877- }
1878- if tt.wantAllow {
1879- if resp.Outcome.Selected == nil || resp.Outcome.Selected.OptionId != acpsdk.PermissionOptionId("allow") {
1880- t.Fatalf("RequestPermission(%s) = %#v, want allow selection", tt.name, resp.Outcome)
1881- }
1882- return
1883- }
1884- if resp.Outcome.Cancelled == nil {
1885- t.Fatalf("RequestPermission(%s) = %#v, want cancelled", tt.name, resp.Outcome)
1886- }
1887- })
1888- }
1889-}
1890-
1891-func newTestACP(t *testing.T, ctx context.Context, mode, readPath string) *ACP {
1892- t.Helper()
1893- client, err := New(ctx, &Config{
1894- Prompt: "decompile this",
1895- Model: "default",
1896- Command: os.Args[0],
1897- Args: []string{"-test.run=TestACPAgentHelperProcess"},
1898- Env: []string{
1899- testACPAgentModeEnv + "=" + mode,
1900- testACPReadPathEnv + "=" + readPath,
1901- },
1902- })
1903- if err != nil {
1904- t.Fatalf("New() error = %v", err)
1905- }
1906- return client
1907-}
1908-
1909-func chdir(t *testing.T, dir string) {
1910- t.Helper()
1911- old, err := os.Getwd()
1912- if err != nil {
1913- t.Fatalf("get cwd: %v", err)
1914- }
1915- if err := os.Chdir(dir); err != nil {
1916- t.Fatalf("chdir %s: %v", dir, err)
1917- }
1918- t.Cleanup(func() {
1919- if err := os.Chdir(old); err != nil {
1920- t.Fatalf("restore cwd %s: %v", old, err)
1921- }
1922- })
1923-}
1924-
1925-func TestACPAgentHelperProcess(t *testing.T) {
1926- mode := os.Getenv(testACPAgentModeEnv)
1927- if mode == "" {
1928- return
1929- }
1930- if mode == "stderr-exit" {
1931- _, _ = fmt.Fprintln(os.Stderr, "test acp adapter failed before initialize")
1932- os.Exit(2)
1933- }
1934-
1935- agent := &testACPAgent{
1936- mode: mode,
1937- readPath: os.Getenv(testACPReadPathEnv),
1938- }
1939- conn := acpsdk.NewAgentSideConnection(agent, os.Stdout, os.Stdin)
1940- agent.conn = conn
1941- <-conn.Done()
1942- os.Exit(0)
1943-}
1944-
1945-type testACPAgent struct {
1946- conn *acpsdk.AgentSideConnection
1947- mode string
1948- readPath string
1949-}
1950-
1951-var _ acpsdk.Agent = (*testACPAgent)(nil)
1952-
1953-func (a *testACPAgent) Authenticate(ctx context.Context, _ acpsdk.AuthenticateRequest) (acpsdk.AuthenticateResponse, error) {
1954- return acpsdk.AuthenticateResponse{}, nil
1955-}
1956-
1957-func (a *testACPAgent) Logout(ctx context.Context, _ acpsdk.LogoutRequest) (acpsdk.LogoutResponse, error) {
1958- return acpsdk.LogoutResponse{}, nil
1959-}
1960-
1961-func (a *testACPAgent) Initialize(ctx context.Context, _ acpsdk.InitializeRequest) (acpsdk.InitializeResponse, error) {
1962- return acpsdk.InitializeResponse{
1963- ProtocolVersion: acpsdk.ProtocolVersionNumber,
1964- AgentCapabilities: acpsdk.AgentCapabilities{LoadSession: false},
1965- }, nil
1966-}
1967-
1968-func (a *testACPAgent) Cancel(ctx context.Context, _ acpsdk.CancelNotification) error {
1969- return nil
1970-}
1971-
1972-func (a *testACPAgent) CloseSession(ctx context.Context, _ acpsdk.CloseSessionRequest) (acpsdk.CloseSessionResponse, error) {
1973- return acpsdk.CloseSessionResponse{}, nil
1974-}
1975-
1976-func (a *testACPAgent) ListSessions(ctx context.Context, _ acpsdk.ListSessionsRequest) (acpsdk.ListSessionsResponse, error) {
1977- return acpsdk.ListSessionsResponse{}, nil
1978-}
1979-
1980-func (a *testACPAgent) NewSession(ctx context.Context, _ acpsdk.NewSessionRequest) (acpsdk.NewSessionResponse, error) {
1981- modelCategory := acpsdk.SessionConfigOptionCategoryModel
1982- options := acpsdk.SessionConfigSelectOptionsUngrouped{{
1983- Name: "Test Model",
1984- Value: acpsdk.SessionConfigValueId("test-model"),
1985- }}
1986- return acpsdk.NewSessionResponse{
1987- SessionId: acpsdk.SessionId("test-session"),
1988- ConfigOptions: []acpsdk.SessionConfigOption{{
1989- Select: &acpsdk.SessionConfigOptionSelect{
1990- Category: &modelCategory,
1991- Id: acpsdk.SessionConfigId("model"),
1992- Name: "Model",
1993- CurrentValue: acpsdk.SessionConfigValueId("test-model"),
1994- Options: acpsdk.SessionConfigSelectOptions{Ungrouped: &options},
1995- },
1996- }},
1997- }, nil
1998-}
1999-
2000-func (a *testACPAgent) Prompt(ctx context.Context, req acpsdk.PromptRequest) (acpsdk.PromptResponse, error) {
2001- switch a.mode {
2002- case "cancelled":
2003- if err := a.sendText(ctx, req.SessionId, "partial output"); err != nil {
2004- return acpsdk.PromptResponse{}, err
2005- }
2006- return acpsdk.PromptResponse{StopReason: acpsdk.StopReasonCancelled}, nil
2007- case "read":
2008- kind := acpsdk.ToolKindRead
2009- perm, err := a.conn.RequestPermission(ctx, acpsdk.RequestPermissionRequest{
2010- SessionId: req.SessionId,
2011- ToolCall: acpsdk.ToolCallUpdate{
2012- ToolCallId: acpsdk.ToolCallId("read-context"),
2013- Kind: &kind,
2014- },
2015- Options: []acpsdk.PermissionOption{
2016- {Kind: acpsdk.PermissionOptionKindAllowOnce, Name: "Allow", OptionId: acpsdk.PermissionOptionId("allow")},
2017- {Kind: acpsdk.PermissionOptionKindRejectOnce, Name: "Reject", OptionId: acpsdk.PermissionOptionId("reject")},
2018- },
2019- })
2020- if err != nil {
2021- return acpsdk.PromptResponse{}, err
2022- }
2023- if perm.Outcome.Selected == nil {
2024- return acpsdk.PromptResponse{}, fmt.Errorf("read permission was not selected")
2025- }
2026- line, limit := 2, 1
2027- file, err := a.conn.ReadTextFile(ctx, acpsdk.ReadTextFileRequest{
2028- SessionId: req.SessionId,
2029- Path: a.readPath,
2030- Line: &line,
2031- Limit: &limit,
2032- })
2033- if err != nil {
2034- return acpsdk.PromptResponse{}, err
2035- }
2036- if err := a.sendText(ctx, req.SessionId, "read:"+file.Content); err != nil {
2037- return acpsdk.PromptResponse{}, err
2038- }
2039- return acpsdk.PromptResponse{StopReason: acpsdk.StopReasonEndTurn}, nil
2040- default:
2041- if err := a.sendText(ctx, req.SessionId, "ok"); err != nil {
2042- return acpsdk.PromptResponse{}, err
2043- }
2044- return acpsdk.PromptResponse{StopReason: acpsdk.StopReasonEndTurn}, nil
2045- }
2046-}
2047-
2048-func (a *testACPAgent) ResumeSession(ctx context.Context, _ acpsdk.ResumeSessionRequest) (acpsdk.ResumeSessionResponse, error) {
2049- return acpsdk.ResumeSessionResponse{}, nil
2050-}
2051-
2052-func (a *testACPAgent) SetSessionConfigOption(ctx context.Context, _ acpsdk.SetSessionConfigOptionRequest) (acpsdk.SetSessionConfigOptionResponse, error) {
2053- return acpsdk.SetSessionConfigOptionResponse{}, nil
2054-}
2055-
2056-func (a *testACPAgent) SetSessionMode(ctx context.Context, _ acpsdk.SetSessionModeRequest) (acpsdk.SetSessionModeResponse, error) {
2057- return acpsdk.SetSessionModeResponse{}, nil
2058-}
2059-
2060-func (a *testACPAgent) sendText(ctx context.Context, sessionID acpsdk.SessionId, text string) error {
2061- return a.conn.SessionUpdate(ctx, acpsdk.SessionNotification{
2062- SessionId: sessionID,
2063- Update: acpsdk.UpdateAgentMessageText(text),
2064- })
2065-}
2066diff --git a/internal/ai/ai.go b/internal/ai/ai.go
2067deleted file mode 100644
2068index 3c4ec6bf1..000000000
2069--- a/internal/ai/ai.go
2070+++ /dev/null
2071@@ -1,363 +0,0 @@
2072-package ai
2073-
2074-import (
2075- "context"
2076- "encoding/json"
2077- "errors"
2078- "fmt"
2079- "slices"
2080- "strings"
2081- "time"
2082-
2083- "github.com/blacktop/ipsw/internal/utils"
2084-
2085- "github.com/apex/log"
2086- "github.com/blacktop/ipsw/internal/ai/acp"
2087- "github.com/blacktop/ipsw/internal/ai/anthropic"
2088- "github.com/blacktop/ipsw/internal/ai/copilot"
2089- "github.com/blacktop/ipsw/internal/ai/gemini"
2090- "github.com/blacktop/ipsw/internal/ai/ollama"
2091- "github.com/blacktop/ipsw/internal/ai/openai"
2092- "github.com/blacktop/ipsw/internal/ai/openrouter"
2093- db "github.com/blacktop/ipsw/internal/db/ai"
2094- model "github.com/blacktop/ipsw/internal/model/ai"
2095- "gorm.io/gorm"
2096-)
2097-
2098-var Providers = []string{
2099- "anthropic",
2100- "claude",
2101- "copilot",
2102- "codex",
2103- "gemini",
2104- "google",
2105- "ollama",
2106- "openai",
2107- "openrouter",
2108-}
2109-
2110-var ProviderAliases = map[string]string{
2111- // Legacy ACP provider names
2112- "claude-code-acp": "claude",
2113- "codex-acp": "codex",
2114- "gemini-acp": "gemini",
2115- // Legacy API provider names
2116- "claude": "claude", // canonical (ACP)
2117- "gemini": "gemini", // canonical (ACP)
2118- // Explicit API aliases
2119- "claude-api": "anthropic",
2120- "gemini-api": "google",
2121-}
2122-
2123-func NormalizeProvider(provider string) string {
2124- provider = strings.TrimSpace(provider)
2125- if provider == "" {
2126- return provider
2127- }
2128- if canonical, ok := ProviderAliases[provider]; ok {
2129- return canonical
2130- }
2131- return provider
2132-}
2133-
2134-func IsValidProvider(provider string) bool {
2135- return slices.Contains(Providers, NormalizeProvider(provider))
2136-}
2137-
2138-type AI interface {
2139- Chat() (string, error)
2140- Models() (map[string]string, error)
2141- // FIXME: dump convienence method to set models from cache
2142- SetModels(map[string]string) (map[string]string, error)
2143- SetModel(string) error
2144- Verify() error
2145- Close() error
2146-}
2147-
2148-type Config struct {
2149- UUID string
2150- Provider string
2151- Prompt string
2152- Model string
2153- Temperature float64
2154- TopP float64
2155- TemperatureSet bool
2156- TopPSet bool
2157- Stream bool
2158- DisableCache bool
2159- Verbose bool
2160- MaxRetries int
2161- RetryBackoff time.Duration
2162-}
2163-
2164-type CachingAI struct {
2165- ai AI
2166- cache db.CacheDB
2167- config *Config
2168-}
2169-
2170-func (c *CachingAI) Chat() (string, error) {
2171- if c.cache != nil && !c.config.DisableCache && !c.config.Stream {
2172- chat, err := c.cache.Get(c.config.UUID, c.config.Provider, c.config.Model, c.config.Prompt, c.config.Temperature, c.config.TopP)
2173- if err == nil && chat != nil {
2174- return chat.Response, nil
2175- }
2176- if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) && !errors.Is(err, model.ErrNotFound) {
2177- log.Warnf("cache get error: %v", err)
2178- }
2179- }
2180-
2181- response, err := utils.RetryWithResult(c.config.MaxRetries+1, c.config.RetryBackoff, func() (string, error) {
2182- resp, err := c.ai.Chat()
2183- if err == nil {
2184- return resp, nil
2185- }
2186- errStr := strings.ToLower(err.Error())
2187- if strings.Contains(errStr, "model not found") ||
2188- strings.Contains(errStr, "invalid model") ||
2189- strings.Contains(errStr, "unknown model") ||
2190- strings.Contains(errStr, "does not exist") ||
2191- strings.Contains(errStr, "404") ||
2192- strings.Contains(errStr, "400") {
2193- log.Warnf("Potential model error detected ('%s'), clearing DB models cache for provider %s", err.Error(), c.config.Provider)
2194- if c.cache != nil {
2195- if delErr := c.cache.DeleteProviderModels(c.config.Provider); delErr != nil {
2196- log.Warnf("Failed to delete provider models from cache for %s: %v", c.config.Provider, delErr)
2197- }
2198- }
2199- // No need to retry if the model is not found
2200- return "", &utils.StopRetryingError{Err: err}
2201- }
2202- return "", err
2203- })
2204- if err != nil {
2205- return "", err
2206- }
2207-
2208- if c.cache != nil && !c.config.DisableCache && !c.config.Stream {
2209- newEntry := &model.ChatResponse{
2210- UUID: c.config.UUID,
2211- Provider: c.config.Provider,
2212- LLMModel: c.config.Model,
2213- Prompt: c.config.Prompt,
2214- Temperature: c.config.Temperature,
2215- TopP: c.config.TopP,
2216- Response: response,
2217- }
2218- if err := c.cache.Set(newEntry); err != nil {
2219- log.Warnf("cache set error: %v", err)
2220- }
2221- }
2222-
2223- return response, nil
2224-}
2225-
2226-func (c *CachingAI) Models() (map[string]string, error) {
2227- if c.cache != nil {
2228- cachedProviderModels, err := c.cache.GetProviderModels(c.config.Provider)
2229- if err == nil && cachedProviderModels != nil && cachedProviderModels.ModelsJSON != "" {
2230- var modelsList map[string]string
2231- if err := json.Unmarshal([]byte(cachedProviderModels.ModelsJSON), &modelsList); err != nil {
2232- return nil, fmt.Errorf("failed to unmarshal cached models for provider %s: %w", c.config.Provider, err)
2233- }
2234- return c.SetModels(modelsList)
2235- } else if err != nil && !errors.Is(err, model.ErrNotFound) && !errors.Is(err, gorm.ErrRecordNotFound) {
2236- return nil, fmt.Errorf("failed to get cached models for provider %s: %w", c.config.Provider, err)
2237- }
2238- }
2239-
2240- log.Debugf("Fetching models for provider %s from underlying AI", c.config.Provider)
2241- models, err := c.ai.Models()
2242- if err != nil {
2243- return nil, fmt.Errorf("failed to get models from underlying AI provider %s: %w", c.config.Provider, err)
2244- }
2245-
2246- if c.cache != nil && len(models) > 0 {
2247- modelsJSON, err := json.Marshal(models)
2248- if err != nil {
2249- return nil, fmt.Errorf("failed to marshal models for provider %s: %w", c.config.Provider, err)
2250- } else {
2251- providerModelsToCache := &model.ProviderModels{
2252- Provider: c.config.Provider,
2253- ModelsJSON: string(modelsJSON),
2254- }
2255- if err := c.cache.SetProviderModels(providerModelsToCache); err != nil {
2256- return nil, fmt.Errorf("failed to set provider models in cache for %s: %w", c.config.Provider, err)
2257- }
2258- }
2259- } else if c.cache != nil && len(models) == 0 {
2260- log.Debugf("Underlying AI returned no models for provider %s. Caching empty list.", c.config.Provider)
2261- modelsJSON, _ := json.Marshal([]string{})
2262- providerModelsToCache := &model.ProviderModels{
2263- Provider: c.config.Provider,
2264- ModelsJSON: string(modelsJSON),
2265- }
2266- if err := c.cache.SetProviderModels(providerModelsToCache); err != nil {
2267- return nil, fmt.Errorf("failed to set provider models in cache for %s: %w", c.config.Provider, err)
2268- }
2269- }
2270-
2271- return models, nil
2272-}
2273-
2274-func (c *CachingAI) SetModel(model string) error {
2275- c.config.Model = model
2276- return c.ai.SetModel(model)
2277-}
2278-
2279-func (c *CachingAI) SetModels(models map[string]string) (map[string]string, error) {
2280- return c.ai.SetModels(models)
2281-}
2282-
2283-func (c *CachingAI) Verify() error {
2284- return c.ai.Verify()
2285-}
2286-
2287-func (c *CachingAI) Close() error {
2288- var errs []error
2289- if c.ai != nil {
2290- if err := c.ai.Close(); err != nil {
2291- errs = append(errs, fmt.Errorf("failed to close underlying AI: %w", err))
2292- }
2293- }
2294- if c.cache != nil {
2295- if err := c.cache.Close(); err != nil {
2296- errs = append(errs, fmt.Errorf("failed to close AI cache: %w", err))
2297- }
2298- }
2299- if len(errs) > 0 {
2300- return fmt.Errorf("errors while closing CachingAI: %v", errs)
2301- }
2302- return nil
2303-}
2304-
2305-func NewAI(ctx context.Context, cfg *Config) (AI, error) {
2306- var baseAI AI
2307- var err error
2308- var cache db.CacheDB
2309-
2310- cfg.Provider = NormalizeProvider(cfg.Provider)
2311-
2312- if !cfg.DisableCache && !cfg.Stream {
2313- cache, err = db.NewCacheDB(cfg.Verbose)
2314- if err != nil {
2315- log.Warnf("Failed to initialize AI cache: %v. Proceeding without DB caching for tokens/chat.", err)
2316- cache = nil
2317- } else {
2318- log.Info("AI caching is enabled")
2319- }
2320- } else {
2321- log.Warn("AI caching is disabled by config")
2322- cache = nil
2323- }
2324-
2325- // Set default values for retry-related fields if not specified
2326- if cfg.MaxRetries <= 0 {
2327- cfg.MaxRetries = 0 // Default: no retries
2328- }
2329-
2330- switch cfg.Provider {
2331- case "claude":
2332- baseAI, err = acp.New(ctx, &acp.Config{
2333- Prompt: cfg.Prompt,
2334- Model: cfg.Model,
2335- Temperature: cfg.Temperature,
2336- TopP: cfg.TopP,
2337- Stream: cfg.Stream,
2338- Command: "npx",
2339- Args: []string{"-y", "@zed-industries/claude-code-acp@latest"},
2340- Verbose: cfg.Verbose,
2341- })
2342- case "anthropic":
2343- baseAI, err = anthropic.NewClaude(ctx, &anthropic.Config{
2344- Prompt: cfg.Prompt,
2345- Model: cfg.Model,
2346- Temperature: cfg.Temperature,
2347- TemperatureSet: cfg.TemperatureSet,
2348- TopP: cfg.TopP,
2349- TopPSet: cfg.TopPSet,
2350- Stream: cfg.Stream,
2351- })
2352- case "copilot":
2353- baseAI, err = copilot.NewCopilot(ctx, &copilot.Config{
2354- Prompt: cfg.Prompt,
2355- Model: cfg.Model,
2356- Temperature: cfg.Temperature,
2357- TopP: cfg.TopP,
2358- Stream: cfg.Stream,
2359- Cache: cache,
2360- })
2361- case "codex":
2362- baseAI, err = acp.New(ctx, &acp.Config{
2363- Prompt: cfg.Prompt,
2364- Model: cfg.Model,
2365- Temperature: cfg.Temperature,
2366- TopP: cfg.TopP,
2367- Stream: cfg.Stream,
2368- Command: "npx",
2369- Args: []string{"-y", "@zed-industries/codex-acp@latest"},
2370- Verbose: cfg.Verbose,
2371- })
2372- case "gemini":
2373- baseAI, err = acp.New(ctx, &acp.Config{
2374- Prompt: cfg.Prompt,
2375- Model: cfg.Model,
2376- Temperature: cfg.Temperature,
2377- TopP: cfg.TopP,
2378- Stream: cfg.Stream,
2379- Command: "npx",
2380- Args: []string{"-y", "@google/gemini-cli@latest", "--experimental-acp"},
2381- Verbose: cfg.Verbose,
2382- })
2383- case "google":
2384- baseAI, err = gemini.NewGemini(ctx, &gemini.Config{
2385- Prompt: cfg.Prompt,
2386- Model: cfg.Model,
2387- Temperature: cfg.Temperature,
2388- TopP: cfg.TopP,
2389- Stream: cfg.Stream,
2390- })
2391- case "ollama":
2392- baseAI, err = ollama.NewOllama(ctx, &ollama.Config{
2393- Prompt: cfg.Prompt,
2394- Model: cfg.Model,
2395- Temperature: cfg.Temperature,
2396- TopP: cfg.TopP,
2397- Stream: cfg.Stream,
2398- })
2399- case "openai":
2400- baseAI, err = openai.NewOpenAI(ctx, &openai.Config{
2401- Prompt: cfg.Prompt,
2402- Model: cfg.Model,
2403- Temperature: cfg.Temperature,
2404- TopP: cfg.TopP,
2405- Stream: cfg.Stream,
2406- })
2407- case "openrouter":
2408- baseAI, err = openrouter.NewOpenRouter(ctx, &openrouter.Config{
2409- Prompt: cfg.Prompt,
2410- Model: cfg.Model,
2411- Temperature: cfg.Temperature,
2412- TopP: cfg.TopP,
2413- Stream: cfg.Stream,
2414- })
2415- default:
2416- return nil, fmt.Errorf("unknown AI provider: %s", cfg.Provider)
2417- }
2418-
2419- if err != nil {
2420- return nil, fmt.Errorf("failed to create base AI provider %s: %w", cfg.Provider, err)
2421- }
2422-
2423- ai := &CachingAI{
2424- ai: baseAI,
2425- cache: cache,
2426- config: cfg,
2427- }
2428-
2429- if _, err := ai.Models(); err != nil {
2430- return nil, fmt.Errorf("failed to prefetch models: %w", err)
2431- }
2432-
2433- return ai, nil
2434-}
2435diff --git a/internal/ai/anthropic/anthropic.go b/internal/ai/anthropic/anthropic.go
2436deleted file mode 100644
2437index 731de7d8a..000000000
2438--- a/internal/ai/anthropic/anthropic.go
2439+++ /dev/null
2440@@ -1,144 +0,0 @@
2441-package anthropic
2442-
2443-import (
2444- "context"
2445- "fmt"
2446-
2447- "github.com/anthropics/anthropic-sdk-go"
2448- "github.com/blacktop/ipsw/internal/ai/utils"
2449-)
2450-
2451-type Config struct {
2452- Prompt string `json:"prompt"`
2453- Model string `json:"model"`
2454- Temperature float64 `json:"temperature"`
2455- TemperatureSet bool `json:"-"`
2456- TopP float64 `json:"top_p"`
2457- TopPSet bool `json:"-"`
2458- Stream bool `json:"stream"`
2459-}
2460-
2461-type Claude struct {
2462- ctx context.Context
2463- conf *Config
2464- cli *anthropic.Client
2465- models map[string]string
2466-}
2467-
2468-func NewClaude(ctx context.Context, conf *Config) (*Claude, error) {
2469- cli := anthropic.NewClient()
2470- claude := &Claude{
2471- ctx: ctx,
2472- conf: conf,
2473- cli: &cli,
2474- }
2475- return claude, nil
2476-}
2477-
2478-func (c *Claude) Models() (map[string]string, error) {
2479- if len(c.models) > 0 {
2480- return c.models, nil
2481- }
2482- if err := c.getModels(); err != nil {
2483- return nil, fmt.Errorf("claude: failed to get models: %w", err)
2484- }
2485- return c.models, nil
2486-}
2487-
2488-func (c *Claude) SetModel(model string) error {
2489- if _, ok := c.models[model]; !ok {
2490- return fmt.Errorf("model '%s' not found", model)
2491- }
2492- c.conf.Model = model
2493- return nil
2494-}
2495-
2496-func (c *Claude) SetModels(models map[string]string) (map[string]string, error) {
2497- c.models = models
2498- return c.models, nil
2499-}
2500-
2501-// Verify checks that the current model configuration is valid
2502-func (c *Claude) Verify() error {
2503- if c.conf.Model == "" {
2504- return fmt.Errorf("no model specified")
2505- }
2506- if len(c.models) == 0 {
2507- if _, err := c.Models(); err != nil {
2508- return fmt.Errorf("failed to fetch models: %v", err)
2509- }
2510- }
2511- modelID, ok := c.models[c.conf.Model]
2512- if !ok {
2513- // Model not found in cache, try refreshing the models list
2514- c.models = make(map[string]string) // Clear cache to force refresh
2515- if _, err := c.Models(); err != nil {
2516- return fmt.Errorf("failed to fetch models: %v", err)
2517- }
2518- // Check again after refresh
2519- modelID, ok = c.models[c.conf.Model]
2520- if !ok {
2521- return fmt.Errorf("model '%s' not found in available models", c.conf.Model)
2522- }
2523- }
2524- if modelID == "" {
2525- return fmt.Errorf("model '%s' has empty ID", c.conf.Model)
2526- }
2527- return nil
2528-}
2529-
2530-func (c *Claude) getModels() error {
2531- models, err := c.cli.Models.List(c.ctx, anthropic.ModelListParams{})
2532- if err != nil {
2533- return fmt.Errorf("failed to list models: %w", err)
2534- }
2535- c.models = make(map[string]string)
2536- for _, model := range models.Data {
2537- c.models[model.DisplayName] = model.ID
2538- }
2539- if len(c.models) == 0 {
2540- return fmt.Errorf("no models found")
2541- }
2542- return nil
2543-}
2544-
2545-func (c *Claude) Chat() (string, error) {
2546- // Verify model configuration before making API call
2547- if err := c.Verify(); err != nil {
2548- return "", fmt.Errorf("invalid model configuration: %w", err)
2549- }
2550- if c.conf.TemperatureSet && c.conf.TopPSet {
2551- return "", fmt.Errorf("claude: temperature and top_p cannot both be set for the same request")
2552- }
2553-
2554- params := anthropic.MessageNewParams{
2555- MaxTokens: 1024,
2556- Messages: []anthropic.MessageParam{
2557- anthropic.NewUserMessage(anthropic.NewTextBlock(c.conf.Prompt)),
2558- },
2559- Model: anthropic.Model(c.models[c.conf.Model]),
2560- ServiceTier: anthropic.MessageNewParamsServiceTierAuto,
2561- }
2562- if c.conf.TemperatureSet {
2563- params.Temperature = anthropic.Float(c.conf.Temperature)
2564- }
2565- if c.conf.TopPSet {
2566- params.TopP = anthropic.Float(c.conf.TopP)
2567- }
2568-
2569- message, err := c.cli.Messages.New(c.ctx, params)
2570- if err != nil {
2571- return "", fmt.Errorf("failed to create message: %w", err)
2572- }
2573-
2574- if len(message.Content) == 0 {
2575- return "", fmt.Errorf("no content returned from message")
2576- }
2577-
2578- return utils.Clean(message.Content[0].Text), nil
2579-}
2580-
2581-// Close implements the ai.AI interface.
2582-func (c *Claude) Close() error {
2583- return nil // No specific resources to close for Claude client
2584-}
2585diff --git a/internal/ai/copilot/copilot.go b/internal/ai/copilot/copilot.go
2586deleted file mode 100644
2587index d678e773a..000000000
2588--- a/internal/ai/copilot/copilot.go
2589+++ /dev/null
2590@@ -1,509 +0,0 @@
2591-package copilot
2592-
2593-import (
2594- "bufio"
2595- "bytes"
2596- "context"
2597- "encoding/json"
2598- "errors"
2599- "fmt"
2600- "io"
2601- "net/http"
2602- "os"
2603- "path/filepath"
2604- "runtime"
2605- "strings"
2606- "time"
2607-
2608- "github.com/apex/log"
2609- "github.com/blacktop/ipsw/internal/ai/utils"
2610- model "github.com/blacktop/ipsw/internal/model/ai"
2611-)
2612-
2613-const (
2614- copilotChatAuthURL = "https://api.github.com/copilot_internal/v2/token"
2615- copilotEditorVersion = "vscode/1.104.1"
2616- copilotUserAgent = "curl/7.81.0" // Necessay to bypass the user-agent check
2617-)
2618-
2619-var CopilotModels = []string{
2620- "Claude 3.5 Sonnet",
2621- "Claude 3.7 Sonnet",
2622- "Claude 3.7 Sonnet Thinking",
2623- "Gemini 2.0 Flash",
2624- "Gemini 2.5 Pro (Preview)",
2625- "o4-mini (Preview)",
2626- "GPT-4.1 (Preview)",
2627-}
2628-
2629-// tokenResponse matches the GitHub auth endpoint
2630-type tokenResponse struct {
2631- AnnotationsEnabled bool `json:"annotations_enabled"`
2632- ChatEnabled bool `json:"chat_enabled"`
2633- ChatJetbrainsEnabled bool `json:"chat_jetbrains_enabled"`
2634- CodeQuoteEnabled bool `json:"code_quote_enabled"`
2635- CodeReviewEnabled bool `json:"code_review_enabled"`
2636- Codesearch bool `json:"codesearch"`
2637- CopilotignoreEnabled bool `json:"copilotignore_enabled"`
2638- Endpoints struct {
2639- API string `json:"api"`
2640- OriginTracker string `json:"origin-tracker"`
2641- Proxy string `json:"proxy"`
2642- Telemetry string `json:"telemetry"`
2643- } `json:"endpoints"`
2644- ExpiresAt int64 `json:"expires_at"`
2645- Individual bool `json:"individual"`
2646- LimitedUserQuotas any `json:"limited_user_quotas"`
2647- LimitedUserResetDate any `json:"limited_user_reset_date"`
2648- Prompt8K bool `json:"prompt_8k"`
2649- PublicSuggestions string `json:"public_suggestions"`
2650- RefreshIn int `json:"refresh_in"`
2651- SKU string `json:"sku"`
2652- SnippyLoadTestEnabled bool `json:"snippy_load_test_enabled"`
2653- Telemetry string `json:"telemetry"`
2654- Token string `json:"token"`
2655- TrackingID string `json:"tracking_id"`
2656- VscElectronFetcherV2 bool `json:"vsc_electron_fetcher_v2"`
2657- Xcode bool `json:"xcode"`
2658- XcodeChat bool `json:"xcode_chat"`
2659- ErrorDetails *struct {
2660- URL string `json:"url,omitempty"`
2661- Message string `json:"message,omitempty"`
2662- Title string `json:"title,omitempty"`
2663- NotificationID string `json:"notification_id,omitempty"`
2664- } `json:"error_details,omitempty"`
2665-}
2666-
2667-type modelsResponse struct {
2668- Data []struct {
2669- Capabilities struct {
2670- Family string `json:"family"`
2671- Limits struct {
2672- MaxContextWindowTokens int `json:"max_context_window_tokens"`
2673- MaxOutputTokens int `json:"max_output_tokens"`
2674- MaxPromptTokens int `json:"max_prompt_tokens"`
2675- } `json:"limits"`
2676- Object string `json:"object"`
2677- Supports struct {
2678- Streaming bool `json:"streaming"`
2679- ToolCalls bool `json:"tool_calls"`
2680- } `json:"supports"`
2681- Tokenizer string `json:"tokenizer"`
2682- Type string `json:"type"`
2683- } `json:"capabilities"`
2684- ID string `json:"id"`
2685- ModelPickerEnabled bool `json:"model_picker_enabled"`
2686- Name string `json:"name"`
2687- Object string `json:"object"`
2688- Preview bool `json:"preview"`
2689- Vendor string `json:"vendor"`
2690- Version string `json:"version"`
2691- Policy struct {
2692- State string `json:"state"`
2693- Terms string `json:"terms"`
2694- } `json:"policy"`
2695- } `json:"data"`
2696- Object string `json:"object"`
2697-}
2698-
2699-// chatMessage represents a single message in the conversation
2700-type chatMessage struct {
2701- Role string `json:"role"`
2702- Content string `json:"content"`
2703-}
2704-
2705-// chatRequest is the payload sent to the chat API
2706-type chatRequest struct {
2707- Intent bool `json:"intent"`
2708- N int `json:"n"`
2709- Stream bool `json:"stream"`
2710- Temperature float64 `json:"temperature"`
2711- TopP float64 `json:"top_p"`
2712- Model string `json:"model"`
2713- Messages []chatMessage `json:"messages"`
2714-}
2715-
2716-// responseEvent models both streaming and non‐streaming replies
2717-type responseEvent struct {
2718- Choices []struct {
2719- FinishReason string `json:"finish_reason"`
2720- Index int `json:"index"`
2721- Delta *struct {
2722- Content *string `json:"content"`
2723- } `json:"delta,omitempty"`
2724- Message *struct {
2725- Role string `json:"role"`
2726- Content string `json:"content"`
2727- } `json:"message,omitempty"`
2728- } `json:"choices"`
2729- Created int64 `json:"created"`
2730- ID string `json:"id"`
2731-}
2732-
2733-// Cache defines the interface for caching Copilot tokens.
2734-type Cache interface {
2735- GetToken(key string) (*model.CopilotToken, error)
2736- SetToken(token *model.CopilotToken) error
2737-}
2738-
2739-type Config struct {
2740- Prompt string `json:"prompt"`
2741- Model string `json:"model"`
2742- Temperature float64 `json:"temperature"`
2743- TopP float64 `json:"top_p"`
2744- Stream bool `json:"stream"`
2745- Cache Cache
2746-}
2747-
2748-type Copilot struct {
2749- ctx context.Context
2750- conf *Config
2751- token *tokenResponse
2752- models map[string]string
2753-}
2754-
2755-const copilotTokenCacheKey = "active_copilot_token"
2756-
2757-func NewCopilot(ctx context.Context, conf *Config) (*Copilot, error) {
2758- return &Copilot{
2759- ctx: ctx,
2760- conf: conf,
2761- models: make(map[string]string),
2762- }, nil
2763-}
2764-
2765-func (c *Copilot) GetToken() error {
2766- var tr *tokenResponse
2767- if c.conf.Cache != nil {
2768- token, err := c.conf.Cache.GetToken(copilotTokenCacheKey)
2769- if err == nil && token != nil {
2770- if time.Now().Before(time.Unix(token.ExpiresAt, 0)) {
2771- // Token from cache is valid and not expired
2772- if err := json.Unmarshal([]byte(token.TokenResponseJSON), &tr); err != nil {
2773- log.FromContext(c.ctx).WithError(err).Warn("Failed to unmarshal cached Copilot token JSON")
2774- tr = nil // Ensure we fetch a new one
2775- }
2776- } else if token.ExpiresAt > 0 { // It was found but expired
2777- log.FromContext(c.ctx).Infof("Cached Copilot API token expired at %s, fetching new one.", time.Unix(token.ExpiresAt, 0).Format(time.RFC1123))
2778- }
2779- } else if err != nil && !errors.Is(err, model.ErrNotFound) {
2780- // Log error only if it's not ErrNotFound (which is expected if cache is empty)
2781- log.FromContext(c.ctx).WithError(err).Warn("Failed to get Copilot token from cache")
2782- }
2783- }
2784-
2785- if tr == nil { // If not cached, expired, or error during cache retrieval/unmarshal
2786- oauth, err := readLocalToken()
2787- if err != nil {
2788- return fmt.Errorf("failed to read local github copilot token: %v", err)
2789- }
2790- apiTokenResp, err := getAPIToken(c.ctx, oauth)
2791- if err != nil {
2792- return fmt.Errorf("failed to get github copilot API token: %v", err)
2793- }
2794- tr = apiTokenResp // Assign the newly fetched token response
2795-
2796- // Check expiry of the newly fetched token before caching
2797- if time.Now().After(time.Unix(tr.ExpiresAt, 0)) {
2798- expiresAt := time.Unix(tr.ExpiresAt, 0).Format(time.RFC1123)
2799- return fmt.Errorf("newly fetched github copilot API token is already expired at %s", expiresAt)
2800- }
2801-
2802- if c.conf.Cache != nil {
2803- tokenJSON, err := json.Marshal(tr)
2804- if err != nil {
2805- log.FromContext(c.ctx).WithError(err).Warn("Failed to marshal Copilot token for caching")
2806- } else {
2807- dbTokenToCache := &model.CopilotToken{
2808- Key: copilotTokenCacheKey,
2809- Token: tr.Token,
2810- ExpiresAt: tr.ExpiresAt,
2811- TokenResponseJSON: string(tokenJSON),
2812- }
2813- if err := c.conf.Cache.SetToken(dbTokenToCache); err != nil {
2814- log.FromContext(c.ctx).WithError(err).Warn("Failed to set Copilot token in cache")
2815- } else {
2816- log.FromContext(c.ctx).Infof("Saved new Copilot API token to cache, expires at %s", time.Unix(tr.ExpiresAt, 0).Format(time.RFC1123))
2817- }
2818- }
2819- }
2820- }
2821-
2822- c.token = tr
2823- return nil
2824-}
2825-
2826-func (c *Copilot) Models() (map[string]string, error) {
2827- if len(c.models) > 0 {
2828- return c.models, nil
2829- }
2830- if err := c.GetToken(); err != nil {
2831- return nil, fmt.Errorf("failed to get Copilot token: %v", err)
2832- }
2833- modelsResponse, err := c.token.getModels(c.ctx)
2834- if err != nil {
2835- return nil, fmt.Errorf("failed to get models: %v", err)
2836- }
2837- for _, model := range modelsResponse.Data {
2838- // if model.ModelPickerEnabled && model.Policy.State == "enabled" {
2839- if model.ModelPickerEnabled {
2840- c.models[model.Name] = model.ID
2841- // fmt.Println(model.Name)
2842- }
2843- }
2844- return c.models, nil
2845-}
2846-
2847-func (c *Copilot) SetModels(models map[string]string) (map[string]string, error) {
2848- c.models = models
2849- return c.models, nil
2850-}
2851-
2852-func (c *Copilot) SetModel(model string) error {
2853- if _, ok := c.models[model]; !ok {
2854- return fmt.Errorf("model '%s' not found", model)
2855- }
2856- c.conf.Model = model
2857- return nil
2858-}
2859-
2860-// Verify checks that the current model configuration is valid
2861-func (c *Copilot) Verify() error {
2862- if c.conf.Model == "" {
2863- return fmt.Errorf("no model specified")
2864- }
2865- if len(c.models) == 0 {
2866- if err := c.GetToken(); err != nil {
2867- return fmt.Errorf("failed to get token: %v", err)
2868- }
2869- if _, err := c.Models(); err != nil {
2870- return fmt.Errorf("failed to fetch models: %v", err)
2871- }
2872- }
2873- modelID, ok := c.models[c.conf.Model]
2874- if !ok {
2875- // Model not found in cache, try refreshing the models list
2876- c.models = make(map[string]string) // Clear cache to force refresh
2877- if err := c.GetToken(); err != nil {
2878- return fmt.Errorf("failed to get token: %v", err)
2879- }
2880- if _, err := c.Models(); err != nil {
2881- return fmt.Errorf("failed to fetch models: %v", err)
2882- }
2883- // Check again after refresh
2884- modelID, ok = c.models[c.conf.Model]
2885- if !ok {
2886- return fmt.Errorf("model '%s' not found in available models (note: newly released models may take time to appear in the API even if enabled in GitHub Copilot settings)", c.conf.Model)
2887- }
2888- }
2889- if modelID == "" {
2890- return fmt.Errorf("model '%s' has empty ID", c.conf.Model)
2891- }
2892- return nil
2893-}
2894-
2895-func readLocalToken() (string, error) {
2896- home, err := os.UserHomeDir()
2897- if err != nil {
2898- return "", err
2899- }
2900-
2901- for _, path := range []string{
2902- "hosts.json",
2903- "apps.json",
2904- } {
2905- configPath := filepath.Join(home, ".config/github-copilot", path)
2906- if runtime.GOOS == "windows" {
2907- configPath = filepath.Join(os.Getenv("LOCALAPPDATA"), "github-copilot", path)
2908- }
2909-
2910- if _, err := os.Stat(configPath); os.IsNotExist(err) {
2911- continue
2912- }
2913-
2914- data, err := os.ReadFile(configPath)
2915- if err != nil {
2916- return "", err
2917- }
2918-
2919- var config map[string]json.RawMessage
2920- if err := json.Unmarshal(data, &config); err != nil {
2921- return "", fmt.Errorf("failed to parse Copilot configuration file at %s: %w", configPath, err)
2922- }
2923-
2924- for key, value := range config {
2925- if key == "github.com" || strings.HasPrefix(key, "github.com:") {
2926- var tokenData map[string]string
2927- if err := json.Unmarshal(value, &tokenData); err != nil {
2928- continue
2929- }
2930- if token, exists := tokenData["oauth_token"]; exists {
2931- return token, nil
2932- }
2933- }
2934- }
2935- }
2936-
2937- return "", fmt.Errorf("token not found in HOME/.config/github-copilot hosts.json OR apps.json")
2938-}
2939-
2940-func getAPIToken(ctx context.Context, oauth string) (*tokenResponse, error) {
2941- req, err := http.NewRequestWithContext(ctx, "GET", copilotChatAuthURL, nil)
2942- if err != nil {
2943- return nil, err
2944- }
2945- req.Header.Set("Authorization", "token "+oauth)
2946- req.Header.Set("Accept", "application/json")
2947- req.Header.Set("Editor-Version", copilotEditorVersion)
2948- req.Header.Set("User-Agent", copilotUserAgent)
2949-
2950- client := &http.Client{Timeout: 10 * time.Second}
2951- resp, err := client.Do(req)
2952- if err != nil {
2953- return nil, err
2954- }
2955- defer resp.Body.Close()
2956-
2957- if resp.StatusCode != http.StatusOK {
2958- body, _ := io.ReadAll(resp.Body)
2959- return nil, fmt.Errorf("auth failed: %s %s", resp.Status, body)
2960- }
2961-
2962- // data, err := io.ReadAll(resp.Body)
2963- // if err != nil {
2964- // return nil, err
2965- // }
2966- // log.Debug(string(data))
2967-
2968- var tr tokenResponse
2969- if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
2970- return nil, err
2971- }
2972-
2973- return &tr, nil
2974-}
2975-
2976-func (api *tokenResponse) getModels(ctx context.Context) (*modelsResponse, error) {
2977- req, err := http.NewRequestWithContext(ctx, "GET", api.Endpoints.API+"/models", nil)
2978- if err != nil {
2979- return nil, err
2980- }
2981- req.Header.Set("Authorization", "Bearer "+api.Token)
2982- req.Header.Set("Content-Type", "application/json")
2983- // req.Header.Set("Copilot-Integration-Id", "vscode-chat")
2984- req.Header.Set("Editor-Version", copilotEditorVersion)
2985- req.Header.Set("User-Agent", copilotUserAgent)
2986- resp, err := http.DefaultClient.Do(req)
2987- if err != nil {
2988- return nil, err
2989- }
2990- defer resp.Body.Close()
2991-
2992- var modelsResponse modelsResponse
2993- if err := json.NewDecoder(resp.Body).Decode(&modelsResponse); err != nil {
2994- return nil, err
2995- }
2996-
2997- return &modelsResponse, nil
2998-}
2999-
3000-func (c *Copilot) Chat() (string, error) {
3001- if err := c.GetToken(); err != nil {
3002- return "", fmt.Errorf("failed to get Copilot token: %v", err)
3003- }
3004-
3005- // Verify model is valid before attempting to chat
3006- if err := c.Verify(); err != nil {
3007- return "", fmt.Errorf("model verification failed: %v", err)
3008- }
3009-
3010- reqBody := chatRequest{
3011- Intent: true,
3012- N: 1,
3013- Stream: c.conf.Stream,
3014- Temperature: c.conf.Temperature,
3015- TopP: c.conf.TopP,
3016- Model: c.models[c.conf.Model],
3017- Messages: []chatMessage{{Role: "user", Content: c.conf.Prompt}},
3018- }
3019- data, err := json.Marshal(reqBody)
3020- if err != nil {
3021- return "", err
3022- }
3023-
3024- req, err := http.NewRequestWithContext(c.ctx, "POST", c.token.Endpoints.API+"/chat/completions", bytes.NewBuffer(data))
3025- if err != nil {
3026- return "", err
3027- }
3028- req.Header.Set("Authorization", "Bearer "+c.token.Token)
3029- req.Header.Set("Content-Type", "application/json")
3030- // req.Header.Set("Copilot-Integration-Id", "vscode-chat")
3031- req.Header.Set("Editor-Version", copilotEditorVersion)
3032- req.Header.Set("User-Agent", copilotUserAgent)
3033-
3034- client := &http.Client{Timeout: 0} // no timeout for streaming
3035- resp, err := client.Do(req)
3036- if err != nil {
3037- return "", err
3038- }
3039- defer resp.Body.Close()
3040-
3041- if resp.StatusCode != http.StatusOK {
3042- body, _ := io.ReadAll(resp.Body)
3043- return "", fmt.Errorf("request failed: %s %s", resp.Status, body)
3044- }
3045-
3046- if c.conf.Stream {
3047- reader := bufio.NewReader(resp.Body)
3048- var out strings.Builder
3049- for {
3050- line, err := reader.ReadString('\n')
3051- if err != nil {
3052- if err == io.EOF {
3053- break
3054- }
3055- return "", err
3056- }
3057- line = strings.TrimSpace(line)
3058- if !strings.HasPrefix(line, "data: ") {
3059- continue
3060- }
3061- payload := strings.TrimPrefix(line, "data: ")
3062- if payload == "[DONE]" {
3063- break
3064- }
3065- var ev responseEvent
3066- if err := json.Unmarshal([]byte(payload), &ev); err != nil {
3067- return "", err
3068- }
3069- for _, choice := range ev.Choices {
3070- if choice.Delta != nil && choice.Delta.Content != nil {
3071- out.WriteString(*choice.Delta.Content)
3072- }
3073- }
3074- }
3075- return utils.Clean(out.String()), nil
3076- } else {
3077- body, err := io.ReadAll(resp.Body)
3078- if err != nil {
3079- return "", err
3080- }
3081- var ev responseEvent
3082- if err := json.Unmarshal(body, &ev); err != nil {
3083- return "", err
3084- }
3085- // print full assistant message
3086- for _, choice := range ev.Choices {
3087- if choice.Message != nil {
3088- return utils.Clean(choice.Message.Content), nil
3089- }
3090- }
3091- }
3092-
3093- return "", fmt.Errorf("no response from API")
3094-}
3095-
3096-// Close implements the ai.AI interface.
3097-func (c *Copilot) Close() error {
3098- return nil // No specific resources to close for Copilot client
3099-}
3100diff --git a/internal/ai/gemini/gemini.go b/internal/ai/gemini/gemini.go
3101deleted file mode 100644
3102index b89bc6f4b..000000000
3103--- a/internal/ai/gemini/gemini.go
3104+++ /dev/null
3105@@ -1,146 +0,0 @@
3106-package gemini
3107-
3108-import (
3109- "context"
3110- "fmt"
3111- "os"
3112- "slices"
3113-
3114- "github.com/blacktop/ipsw/internal/ai/utils"
3115- "google.golang.org/genai"
3116-)
3117-
3118-type Config struct {
3119- Prompt string `json:"prompt"`
3120- Model string `json:"model"`
3121- Temperature float64 `json:"temperature"`
3122- TopP float64 `json:"top_p"`
3123- Stream bool `json:"stream"`
3124-}
3125-
3126-type Gemini struct {
3127- ctx context.Context
3128- conf *Config
3129- cli *genai.Client
3130- models map[string]string
3131-}
3132-
3133-func NewGemini(ctx context.Context, conf *Config) (*Gemini, error) {
3134- apiKey := os.Getenv("GEMINI_API_KEY")
3135- if apiKey == "" {
3136- return nil, fmt.Errorf("failed to create google gemini client: GEMINI_API_KEY environment variable is not set")
3137- }
3138- cli, err := genai.NewClient(ctx, &genai.ClientConfig{
3139- APIKey: apiKey,
3140- Backend: genai.BackendGeminiAPI,
3141- })
3142- if err != nil {
3143- return nil, fmt.Errorf("failed to create google gemini client: %w", err)
3144- }
3145- gemini := &Gemini{
3146- ctx: ctx,
3147- conf: conf,
3148- cli: cli,
3149- }
3150- return gemini, nil
3151-}
3152-
3153-func (c *Gemini) Models() (map[string]string, error) {
3154- if len(c.models) > 0 {
3155- return c.models, nil
3156- }
3157- if err := c.getModels(); err != nil {
3158- return nil, fmt.Errorf("gemini: failed to get models: %w", err)
3159- }
3160- return c.models, nil
3161-}
3162-
3163-func (c *Gemini) SetModel(model string) error {
3164- if _, ok := c.models[model]; !ok {
3165- return fmt.Errorf("model '%s' not found", model)
3166- }
3167- c.conf.Model = model
3168- return nil
3169-}
3170-
3171-func (c *Gemini) SetModels(models map[string]string) (map[string]string, error) {
3172- c.models = models
3173- return c.models, nil
3174-}
3175-
3176-// Verify checks that the current model configuration is valid
3177-func (c *Gemini) Verify() error {
3178- if c.conf.Model == "" {
3179- return fmt.Errorf("no model specified")
3180- }
3181- if len(c.models) == 0 {
3182- if _, err := c.Models(); err != nil {
3183- return fmt.Errorf("failed to fetch models: %v", err)
3184- }
3185- }
3186- modelID, ok := c.models[c.conf.Model]
3187- if !ok {
3188- // Model not found in cache, try refreshing the models list
3189- c.models = make(map[string]string) // Clear cache to force refresh
3190- if _, err := c.Models(); err != nil {
3191- return fmt.Errorf("failed to fetch models: %v", err)
3192- }
3193- // Check again after refresh
3194- modelID, ok = c.models[c.conf.Model]
3195- if !ok {
3196- return fmt.Errorf("model '%s' not found in available models", c.conf.Model)
3197- }
3198- }
3199- if modelID == "" {
3200- return fmt.Errorf("model '%s' has empty ID", c.conf.Model)
3201- }
3202- return nil
3203-}
3204-
3205-func (c *Gemini) getModels() error {
3206- models, err := c.cli.Models.List(c.ctx, &genai.ListModelsConfig{})
3207- if err != nil {
3208- return fmt.Errorf("failed to list models: %w", err)
3209- }
3210- c.models = make(map[string]string)
3211- for _, model := range models.Items {
3212- if slices.Contains(model.SupportedActions, "generateContent") {
3213- c.models[model.DisplayName] = model.Name
3214- }
3215- }
3216- if len(c.models) == 0 {
3217- return fmt.Errorf("no models found")
3218- }
3219- return nil
3220-}
3221-
3222-func (c *Gemini) Chat() (string, error) {
3223- // Verify model configuration before making API call
3224- if err := c.Verify(); err != nil {
3225- return "", fmt.Errorf("invalid model configuration: %w", err)
3226- }
3227-
3228- chat, err := c.cli.Chats.Create(c.ctx,
3229- c.models[c.conf.Model],
3230- &genai.GenerateContentConfig{
3231- Temperature: new(float32(c.conf.Temperature)),
3232- TopP: new(float32(c.conf.TopP)),
3233- },
3234- nil, // history
3235- )
3236- if err != nil {
3237- return "", fmt.Errorf("failed to create message: %w", err)
3238- }
3239-
3240- message, err := chat.SendMessage(c.ctx, genai.Part{Text: c.conf.Prompt})
3241- if err != nil {
3242- return "", fmt.Errorf("failed to send message: %w", err)
3243- }
3244-
3245- return utils.Clean(message.Text()), nil
3246-}
3247-
3248-// Close implements the ai.AI interface.
3249-func (g *Gemini) Close() error {
3250- return nil // No specific resources to close for Gemini client
3251-}
3252diff --git a/internal/ai/ollama/ollama.go b/internal/ai/ollama/ollama.go
3253deleted file mode 100644
3254index 8612eb86f..000000000
3255--- a/internal/ai/ollama/ollama.go
3256+++ /dev/null
3257@@ -1,160 +0,0 @@
3258-package ollama
3259-
3260-import (
3261- "context"
3262- "fmt"
3263-
3264- "github.com/blacktop/ipsw/internal/ai/utils"
3265- "github.com/ollama/ollama/api"
3266-)
3267-
3268-type Config struct {
3269- Prompt string `json:"prompt"`
3270- Model string `json:"model"`
3271- Temperature float64 `json:"temperature"`
3272- TopP float64 `json:"top_p"`
3273- Stream bool `json:"stream"`
3274-}
3275-
3276-type Ollama struct {
3277- ctx context.Context
3278- cfg *Config
3279- cli *api.Client
3280- models map[string]string
3281-}
3282-
3283-func NewOllama(ctx context.Context, cfg *Config) (*Ollama, error) {
3284- cli, err := api.ClientFromEnvironment()
3285- if err != nil {
3286- return nil, fmt.Errorf("could not create ollama client: %w", err)
3287- }
3288- o := &Ollama{
3289- ctx: ctx,
3290- cfg: cfg,
3291- cli: cli,
3292- }
3293- if err := o.getModels(); err != nil {
3294- return nil, fmt.Errorf("could not get models: %w", err)
3295- }
3296- if cfg.Model != "" {
3297- if _, ok := o.models[cfg.Model]; !ok {
3298- return nil, fmt.Errorf("model %s not found", cfg.Model)
3299- }
3300- }
3301- if cfg.Temperature < 0 || cfg.Temperature > 1 {
3302- return nil, fmt.Errorf("temperature must be between 0 and 1")
3303- }
3304- if cfg.TopP < 0 || cfg.TopP > 1 {
3305- return nil, fmt.Errorf("top_p must be between 0 and 1")
3306- }
3307- if cfg.Prompt == "" {
3308- return nil, fmt.Errorf("prompt cannot be empty")
3309- }
3310- return o, nil
3311-}
3312-
3313-func (o *Ollama) Chat() (string, error) {
3314- // Verify model configuration before making API call
3315- if err := o.Verify(); err != nil {
3316- return "", fmt.Errorf("invalid model configuration: %w", err)
3317- }
3318-
3319- systemMsg := "Provide very brief, concise responses"
3320-
3321- messages := []api.Message{
3322- {
3323- Role: "system",
3324- Content: systemMsg,
3325- },
3326- {
3327- Role: "user",
3328- Content: o.cfg.Prompt,
3329- },
3330- }
3331-
3332- var out string
3333-
3334- respFunc := func(cr api.ChatResponse) error {
3335- out = cr.Message.Content
3336- return nil
3337- }
3338-
3339- if err := o.cli.Chat(context.Background(), &api.ChatRequest{
3340- Model: o.cfg.Model,
3341- Messages: messages,
3342- Stream: new(bool),
3343- }, respFunc); err != nil {
3344- return "", fmt.Errorf("failed to chat with ollama: %w", err)
3345- }
3346-
3347- if out == "" {
3348- return "", fmt.Errorf("ollama returned empty response")
3349- }
3350-
3351- return utils.Clean(out), nil
3352-}
3353-
3354-func (o *Ollama) Models() (map[string]string, error) {
3355- if len(o.models) > 0 {
3356- return o.models, nil
3357- }
3358- if err := o.getModels(); err != nil {
3359- return nil, fmt.Errorf("ollama: failed to get models: %w", err)
3360- }
3361- return o.models, nil
3362-}
3363-
3364-func (o *Ollama) SetModel(model string) error {
3365- if _, ok := o.models[model]; !ok {
3366- return fmt.Errorf("model '%s' not found", model)
3367- }
3368- o.cfg.Model = model
3369- return nil
3370-}
3371-
3372-func (o *Ollama) SetModels(models map[string]string) (map[string]string, error) {
3373- return o.models, nil // cache is not used for local models
3374-}
3375-
3376-// Verify checks that the current model configuration is valid
3377-func (o *Ollama) Verify() error {
3378- if o.cfg.Model == "" {
3379- return fmt.Errorf("no model specified")
3380- }
3381- if len(o.models) == 0 {
3382- if _, err := o.Models(); err != nil {
3383- return fmt.Errorf("failed to fetch models: %v", err)
3384- }
3385- }
3386- if _, ok := o.models[o.cfg.Model]; !ok {
3387- // Model not found in cache, try refreshing the models list
3388- o.models = make(map[string]string) // Clear cache to force refresh
3389- if _, err := o.Models(); err != nil {
3390- return fmt.Errorf("failed to fetch models: %v", err)
3391- }
3392- // Check again after refresh
3393- if _, ok := o.models[o.cfg.Model]; !ok {
3394- return fmt.Errorf("model '%s' not found in available models", o.cfg.Model)
3395- }
3396- }
3397- return nil
3398-}
3399-
3400-func (o *Ollama) getModels() error {
3401- o.models = make(map[string]string)
3402-
3403- models, err := o.cli.List(o.ctx)
3404- if err != nil {
3405- return fmt.Errorf("failed to get models: %w", err)
3406- }
3407- for _, model := range models.Models {
3408- o.models[model.Name] = model.Model
3409- }
3410-
3411- return nil
3412-}
3413-
3414-// Close implements the ai.AI interface.
3415-func (o *Ollama) Close() error {
3416- return nil // No specific resources to close for Ollama client
3417-}
3418diff --git a/internal/ai/openai/openai.go b/internal/ai/openai/openai.go
3419deleted file mode 100644
3420index 3213fa029..000000000
3421--- a/internal/ai/openai/openai.go
3422+++ /dev/null
3423@@ -1,131 +0,0 @@
3424-package openai
3425-
3426-import (
3427- "context"
3428- "fmt"
3429-
3430- "github.com/blacktop/ipsw/internal/ai/utils"
3431- "github.com/openai/openai-go"
3432-)
3433-
3434-type Config struct {
3435- Prompt string `json:"prompt"`
3436- Model string `json:"model"`
3437- Temperature float64 `json:"temperature"`
3438- TopP float64 `json:"top_p"`
3439- Stream bool `json:"stream"`
3440-}
3441-
3442-type OpenAI struct {
3443- ctx context.Context
3444- conf *Config
3445- cli *openai.Client
3446- models map[string]string
3447-}
3448-
3449-func NewOpenAI(ctx context.Context, conf *Config) (*OpenAI, error) {
3450- cli := openai.NewClient()
3451- openai := &OpenAI{
3452- ctx: ctx,
3453- conf: conf,
3454- cli: &cli,
3455- }
3456- return openai, nil
3457-}
3458-
3459-func (c *OpenAI) Models() (map[string]string, error) {
3460- if len(c.models) > 0 {
3461- return c.models, nil
3462- }
3463- if err := c.getModels(); err != nil {
3464- return nil, fmt.Errorf("openai: failed to get models: %w", err)
3465- }
3466- return c.models, nil
3467-}
3468-
3469-func (c *OpenAI) SetModel(model string) error {
3470- if _, ok := c.models[model]; !ok {
3471- return fmt.Errorf("model '%s' not found", model)
3472- }
3473- c.conf.Model = model
3474- return nil
3475-}
3476-
3477-func (c *OpenAI) SetModels(models map[string]string) (map[string]string, error) {
3478- c.models = models
3479- return c.models, nil
3480-}
3481-
3482-// Verify checks that the current model configuration is valid
3483-func (c *OpenAI) Verify() error {
3484- if c.conf.Model == "" {
3485- return fmt.Errorf("no model specified")
3486- }
3487- if len(c.models) == 0 {
3488- if _, err := c.Models(); err != nil {
3489- return fmt.Errorf("failed to fetch models: %v", err)
3490- }
3491- }
3492- modelID, ok := c.models[c.conf.Model]
3493- if !ok {
3494- // Model not found in cache, try refreshing the models list
3495- c.models = make(map[string]string) // Clear cache to force refresh
3496- if _, err := c.Models(); err != nil {
3497- return fmt.Errorf("failed to fetch models: %v", err)
3498- }
3499- // Check again after refresh
3500- modelID, ok = c.models[c.conf.Model]
3501- if !ok {
3502- return fmt.Errorf("model '%s' not found in available models", c.conf.Model)
3503- }
3504- }
3505- if modelID == "" {
3506- return fmt.Errorf("model '%s' has empty ID", c.conf.Model)
3507- }
3508- return nil
3509-}
3510-
3511-func (c *OpenAI) getModels() error {
3512- models, err := c.cli.Models.List(c.ctx)
3513- if err != nil {
3514- return fmt.Errorf("failed to list models: %w", err)
3515- }
3516- c.models = make(map[string]string)
3517- for _, model := range models.Data {
3518- c.models[model.ID] = model.ID
3519- }
3520- if len(c.models) == 0 {
3521- return fmt.Errorf("no models found")
3522- }
3523- return nil
3524-}
3525-
3526-func (c *OpenAI) Chat() (string, error) {
3527- // Verify model configuration before making API call
3528- if err := c.Verify(); err != nil {
3529- return "", fmt.Errorf("invalid model configuration: %w", err)
3530- }
3531-
3532- message, err := c.cli.Chat.Completions.New(c.ctx, openai.ChatCompletionNewParams{
3533- Messages: []openai.ChatCompletionMessageParamUnion{
3534- openai.UserMessage(c.conf.Prompt),
3535- },
3536- Model: c.models[c.conf.Model],
3537- Temperature: openai.Float(c.conf.Temperature),
3538- TopP: openai.Float(c.conf.TopP),
3539- })
3540- if err != nil {
3541- return "", fmt.Errorf("failed to create message: %w", err)
3542- }
3543-
3544- if len(message.Choices) == 0 {
3545- return "", fmt.Errorf("no content returned from message")
3546- }
3547-
3548- return utils.Clean(message.Choices[0].Message.Content), nil
3549-}
3550-
3551-// Close implements the ai.AI interface.
3552-func (o *OpenAI) Close() error {
3553- return nil // No specific resources to close for OpenAI client
3554-}
3555diff --git a/internal/ai/openrouter/openrouter.go b/internal/ai/openrouter/openrouter.go
3556deleted file mode 100644
3557index df7c3856a..000000000
3558--- a/internal/ai/openrouter/openrouter.go
3559+++ /dev/null
3560@@ -1,259 +0,0 @@
3561-package openrouter
3562-
3563-import (
3564- "bytes"
3565- "context"
3566- "encoding/json"
3567- "fmt"
3568- "io"
3569- "net/http"
3570- "os"
3571- "time"
3572-
3573- "github.com/blacktop/ipsw/internal/ai/utils"
3574-)
3575-
3576-const (
3577- openrouterChatCompletionsEndpoint = "https://openrouter.ai/api/v1/chat/completions"
3578- openrouterModelsEndpoint = "https://openrouter.ai/api/v1/models"
3579-)
3580-
3581-// Config holds the configuration for the OpenRouter LLM API client
3582-type Config struct {
3583- Prompt string `json:"prompt"`
3584- Model string `json:"model"`
3585- Temperature float64 `json:"temperature"`
3586- TopP float64 `json:"top_p"`
3587- Stream bool `json:"stream"`
3588-}
3589-
3590-// OpenRouter represents a client for the OpenRouter API
3591-type OpenRouter struct {
3592- ctx context.Context
3593- conf *Config
3594- client *http.Client
3595- models map[string]string
3596- apiKey string
3597-}
3598-
3599-// chatMessage represents a single message in the conversation
3600-type chatMessage struct {
3601- Role string `json:"role"`
3602- Content string `json:"content"`
3603-}
3604-
3605-// chatRequest is the payload sent to the OpenRouter chat API
3606-type chatRequest struct {
3607- Model string `json:"model"`
3608- Messages []chatMessage `json:"messages"`
3609- Temperature float64 `json:"temperature"`
3610- TopP float64 `json:"top_p"`
3611- Stream bool `json:"stream"`
3612-}
3613-
3614-// chatResponse represents the response from the OpenRouter chat API
3615-type chatResponse struct {
3616- ID string `json:"id"`
3617- Object string `json:"object"`
3618- Created int64 `json:"created"`
3619- Model string `json:"model"`
3620- Choices []struct {
3621- Message struct {
3622- Role string `json:"role"`
3623- Content string `json:"content"`
3624- } `json:"message"`
3625- FinishReason string `json:"finish_reason"`
3626- } `json:"choices"`
3627- Usage struct {
3628- PromptTokens int `json:"prompt_tokens"`
3629- CompletionTokens int `json:"completion_tokens"`
3630- TotalTokens int `json:"total_tokens"`
3631- } `json:"usage"`
3632-}
3633-
3634-// modelsResponse represents the response from the OpenRouter models API
3635-type modelsResponse struct {
3636- Data []struct {
3637- ID string `json:"id"`
3638- Name string `json:"name"`
3639- Description string `json:"description"`
3640- Context_length int `json:"context_length"`
3641- PricePrompt float64 `json:"pricing.prompt"`
3642- PriceCompletion float64 `json:"pricing.completion"`
3643- } `json:"data"`
3644-}
3645-
3646-// NewOpenRouter creates a new OpenRouter API client
3647-func NewOpenRouter(ctx context.Context, conf *Config) (*OpenRouter, error) {
3648- apiKey := os.Getenv("OPENROUTER_API_KEY")
3649- if apiKey == "" {
3650- return nil, fmt.Errorf("failed to create OpenRouter client: OPENROUTER_API_KEY environment variable is not set")
3651- }
3652-
3653- client := &OpenRouter{
3654- ctx: ctx,
3655- conf: conf,
3656- client: &http.Client{Timeout: 300 * time.Second},
3657- models: make(map[string]string),
3658- apiKey: apiKey,
3659- }
3660-
3661- return client, nil
3662-}
3663-
3664-// Models returns the available models from OpenRouter
3665-func (c *OpenRouter) Models() (map[string]string, error) {
3666- if len(c.models) > 0 {
3667- return c.models, nil
3668- }
3669- modelsResponse, err := c.getModels()
3670- if err != nil {
3671- return nil, fmt.Errorf("openrouter: failed to get models: %w", err)
3672- }
3673-
3674- // Populate the models map with the model IDs
3675- for _, model := range modelsResponse.Data {
3676- c.models[model.Name] = model.ID
3677- }
3678-
3679- return c.models, nil
3680-}
3681-
3682-// SetModel sets the model to use for the OpenRouter client
3683-func (c *OpenRouter) SetModel(model string) error {
3684- if _, ok := c.models[model]; !ok {
3685- return fmt.Errorf("model '%s' not found", model)
3686- }
3687- c.conf.Model = model
3688- return nil
3689-}
3690-
3691-// SetModels sets the available models for the OpenRouter client
3692-func (c *OpenRouter) SetModels(models map[string]string) (map[string]string, error) {
3693- c.models = models
3694- return c.models, nil
3695-}
3696-
3697-// Verify checks that the current model configuration is valid
3698-func (c *OpenRouter) Verify() error {
3699- if c.conf.Model == "" {
3700- return fmt.Errorf("no model specified")
3701- }
3702- if len(c.models) == 0 {
3703- if _, err := c.Models(); err != nil {
3704- return fmt.Errorf("failed to fetch models: %v", err)
3705- }
3706- }
3707- modelID, ok := c.models[c.conf.Model]
3708- if !ok {
3709- // Model not found in cache, try refreshing the models list
3710- c.models = make(map[string]string) // Clear cache to force refresh
3711- if _, err := c.Models(); err != nil {
3712- return fmt.Errorf("failed to fetch models: %v", err)
3713- }
3714- // Check again after refresh
3715- modelID, ok = c.models[c.conf.Model]
3716- if !ok {
3717- return fmt.Errorf("model '%s' not found in available models", c.conf.Model)
3718- }
3719- }
3720- if modelID == "" {
3721- return fmt.Errorf("model '%s' has empty ID", c.conf.Model)
3722- }
3723- return nil
3724-}
3725-
3726-// getModels retrieves the available models from OpenRouter API
3727-func (c *OpenRouter) getModels() (*modelsResponse, error) {
3728- req, err := http.NewRequestWithContext(c.ctx, "GET", openrouterModelsEndpoint, nil)
3729- if err != nil {
3730- return nil, fmt.Errorf("failed to create request: %w", err)
3731- }
3732-
3733- c.setRequestHeaders(req)
3734-
3735- resp, err := c.client.Do(req)
3736- if err != nil {
3737- return nil, fmt.Errorf("failed to send request: %w", err)
3738- }
3739- defer resp.Body.Close()
3740-
3741- if resp.StatusCode != http.StatusOK {
3742- body, _ := io.ReadAll(resp.Body)
3743- return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
3744- }
3745-
3746- var response modelsResponse
3747- if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
3748- return nil, fmt.Errorf("failed to decode response: %w", err)
3749- }
3750-
3751- return &response, nil
3752-}
3753-
3754-// Chat sends a message to the OpenRouter API and returns the response
3755-func (c *OpenRouter) Chat() (string, error) {
3756- // Verify model configuration before making API call
3757- if err := c.Verify(); err != nil {
3758- return "", fmt.Errorf("invalid model configuration: %w", err)
3759- }
3760-
3761- reqBody := chatRequest{
3762- Model: c.models[c.conf.Model],
3763- Messages: []chatMessage{{Role: "user", Content: c.conf.Prompt}},
3764- Temperature: c.conf.Temperature,
3765- TopP: c.conf.TopP,
3766- Stream: false, // We don't support streaming yet
3767- }
3768-
3769- data, err := json.Marshal(reqBody)
3770- if err != nil {
3771- return "", fmt.Errorf("failed to marshal request: %w", err)
3772- }
3773-
3774- req, err := http.NewRequestWithContext(c.ctx, "POST", openrouterChatCompletionsEndpoint, bytes.NewBuffer(data))
3775- if err != nil {
3776- return "", fmt.Errorf("failed to create request: %w", err)
3777- }
3778-
3779- c.setRequestHeaders(req)
3780-
3781- resp, err := c.client.Do(req)
3782- if err != nil {
3783- return "", fmt.Errorf("failed to send request: %w", err)
3784- }
3785- defer resp.Body.Close()
3786-
3787- if resp.StatusCode != http.StatusOK {
3788- body, _ := io.ReadAll(resp.Body)
3789- return "", fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
3790- }
3791-
3792- var response chatResponse
3793- if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
3794- return "", fmt.Errorf("failed to decode response: %w", err)
3795- }
3796-
3797- if len(response.Choices) == 0 {
3798- return "", fmt.Errorf("no response choices returned")
3799- }
3800-
3801- return utils.Clean(response.Choices[0].Message.Content), nil
3802-}
3803-
3804-// Close implements the ai.AI interface
3805-func (c *OpenRouter) Close() error {
3806- return nil // No specific resources to close for OpenRouter client
3807-}
3808-
3809-// setRequestHeaders sets the common headers for OpenRouter API requests
3810-func (c *OpenRouter) setRequestHeaders(req *http.Request) {
3811- req.Header.Set("Authorization", "Bearer "+c.apiKey)
3812- req.Header.Set("Content-Type", "application/json")
3813- req.Header.Set("Accept", "application/json")
3814- // Set HTTP-Referer and X-Title headers only if OPENROUTER_CLIENT_TITLE is set
3815- if clientTitle := os.Getenv("OPENROUTER_CLIENT_TITLE"); clientTitle != "" {
3816- req.Header.Set("HTTP-Referer", "https://github.com/blacktop/ipsw")
3817- req.Header.Set("X-Title", clientTitle)
3818- }
3819-}
3820diff --git a/internal/ai/utils/utils.go b/internal/ai/utils/utils.go
3821deleted file mode 100644
3822index 1ea0d454f..000000000
3823--- a/internal/ai/utils/utils.go
3824+++ /dev/null
3825@@ -1,27 +0,0 @@
3826-package utils
3827-
3828-import (
3829- "regexp"
3830- "strings"
3831-)
3832-
3833-func removeThink(content string) string {
3834- if strings.HasPrefix(content, "<think>") {
3835- if _, rest, found := strings.Cut(content, "</think>"); found {
3836- return rest
3837- }
3838- }
3839- return content
3840-}
3841-
3842-func Clean(content string) string {
3843- content = removeThink(content)
3844- content = strings.TrimSpace(content)
3845- // Use regex to extract code from Markdown code blocks
3846- codeBlockRegex := regexp.MustCompile("(?s)```(?:[a-zA-Z0-9_+-]*\\n)?(.*?)\\n?```")
3847- matches := codeBlockRegex.FindStringSubmatch(content)
3848- if len(matches) > 1 {
3849- return strings.TrimSpace(matches[1])
3850- }
3851- return content
3852-}
3853diff --git a/internal/commands/disass/disass.go b/internal/commands/disass/disass.go
3854deleted file mode 100644
3855index 6e6bf44fc..000000000
3856--- a/internal/commands/disass/disass.go
3857+++ /dev/null
3858@@ -1,145 +0,0 @@
3859-package disass
3860-
3861-import (
3862- "context"
3863- "fmt"
3864- "maps"
3865- "math"
3866- "slices"
3867- "sort"
3868- "strconv"
3869- "strings"
3870- "time"
3871-
3872- "github.com/AlecAivazis/survey/v2"
3873- "github.com/AlecAivazis/survey/v2/terminal"
3874- "github.com/alecthomas/chroma/v2/quick"
3875- "github.com/apex/log"
3876- "github.com/blacktop/ipsw/internal/ai"
3877- "github.com/blacktop/ipsw/pkg/disass"
3878- "github.com/briandowns/spinner"
3879- "github.com/fatih/color"
3880- "github.com/spf13/cobra"
3881- "github.com/spf13/viper"
3882-)
3883-
3884-type Config struct {
3885- UUID string
3886- LLM string
3887- Language string
3888- Model string
3889- Temperature float64
3890- TopP float64
3891- TemperatureSet bool
3892- TopPSet bool
3893- Stream bool
3894- DisableCache bool
3895- Verbose bool
3896- Color bool
3897- Theme string
3898- MaxRetries int
3899- RetryBackoff time.Duration
3900-}
3901-
3902-func Decompile(asm string, cfg *Config) (string, error) {
3903- promptFmt, lexer, err := disass.GetPrompt(asm, cfg.Language)
3904- if err != nil {
3905- return "", fmt.Errorf("failed to get prompt format string and syntax highlight lexer: %v", err)
3906- }
3907- llm, err := ai.NewAI(context.Background(), &ai.Config{
3908- UUID: cfg.UUID,
3909- Provider: cfg.LLM,
3910- Prompt: fmt.Sprintf(promptFmt, asm),
3911- Model: cfg.Model,
3912- Temperature: cfg.Temperature,
3913- TemperatureSet: cfg.TemperatureSet,
3914- TopP: cfg.TopP,
3915- TopPSet: cfg.TopPSet,
3916- Stream: cfg.Stream,
3917- DisableCache: cfg.DisableCache,
3918- Verbose: cfg.Verbose,
3919- MaxRetries: cfg.MaxRetries,
3920- RetryBackoff: cfg.RetryBackoff,
3921- })
3922- if err != nil {
3923- return "", fmt.Errorf("failed to create llm client: %v", err)
3924- }
3925- if cfg.Model == "" {
3926- modelMap, err := llm.Models()
3927- if err != nil {
3928- return "", fmt.Errorf("failed to get llm models: %v", err)
3929- }
3930- models := slices.Collect(maps.Keys(modelMap))
3931- sort.Strings(models)
3932-
3933- // If the provider only offers a single 'default' model, don't bother prompting.
3934- if len(models) == 1 {
3935- choice := models[0]
3936- modelID := modelMap[choice]
3937- if modelID == "" {
3938- modelID = choice
3939- }
3940- if choice == "default" || modelID == "default" {
3941- if err := llm.SetModel("default"); err != nil {
3942- return "", fmt.Errorf("failed to set llm model: %v", err)
3943- }
3944- goto decompile
3945- }
3946- }
3947-
3948- var choice string
3949- prompt := &survey.Select{
3950- Message: "Select model to use:",
3951- Options: models,
3952- PageSize: 10,
3953- }
3954- if err := survey.AskOne(prompt, &choice); err == terminal.InterruptErr {
3955- log.Warn("Exiting...")
3956- return "", nil
3957- }
3958- if err := llm.SetModel(choice); err != nil {
3959- return "", fmt.Errorf("failed to set llm model: %v", err)
3960- }
3961- }
3962-
3963-decompile:
3964- s := spinner.New(spinner.CharSets[38], 100*time.Millisecond)
3965- s.Prefix = color.BlueString(" • Decompiling... ")
3966- s.Start()
3967-
3968- decmp, err := llm.Chat()
3969- s.Stop()
3970- if err != nil {
3971- return "", fmt.Errorf("failed to decompile via llm: %v", err)
3972- }
3973- if cfg.Color {
3974- var buf strings.Builder
3975- if err := quick.Highlight(&buf, "\n"+string(decmp)+"\n", lexer, "terminal256", cfg.Theme); err != nil {
3976- return "", err
3977- }
3978- return buf.String(), nil
3979- }
3980- return decmp, nil
3981-}
3982-
3983-// FlagWasProvided reports whether a CLI flag was explicitly set via the command line
3984-// or supplied through a config file. This allows callers to distinguish user intent
3985-// from default values when forwarding options to downstream systems.
3986-func FlagWasProvided(cmd *cobra.Command, flagName, viperKey string) bool {
3987- if cmd.Flags().Changed(flagName) {
3988- return true
3989- }
3990- if viper.InConfig(viperKey) {
3991- return true
3992- }
3993- flag := cmd.Flags().Lookup(flagName)
3994- if flag == nil {
3995- return false
3996- }
3997- defVal, err := strconv.ParseFloat(flag.DefValue, 64)
3998- if err != nil {
3999- return false
4000- }
4001- current := viper.GetFloat64(viperKey)
4002- return math.Abs(current-defVal) > 1e-9
4003-}
4004diff --git a/internal/db/ai/cache.go b/internal/db/ai/cache.go
4005deleted file mode 100644
4006index 03e706b20..000000000
4007--- a/internal/db/ai/cache.go
4008+++ /dev/null
4009@@ -1,181 +0,0 @@
4010-package ai
4011-
4012-import (
4013- "errors"
4014- "fmt"
4015- "os"
4016- "path/filepath"
4017-
4018- model "github.com/blacktop/ipsw/internal/model/ai"
4019- "github.com/glebarez/sqlite"
4020- "gorm.io/gorm"
4021- "gorm.io/gorm/clause"
4022- "gorm.io/gorm/logger"
4023-)
4024-
4025-const cacheDBName = "dec.db"
4026-
4027-// CacheDB is the interface for AI response caching.
4028-type CacheDB interface {
4029- Get(uuid, provider, modelName, prompt string, temperature, topP float64) (*model.ChatResponse, error)
4030- Set(entry *model.ChatResponse) error
4031- // Copilot Token Caching
4032- GetToken(key string) (*model.CopilotToken, error)
4033- SetToken(token *model.CopilotToken) error
4034- // Provider Models Caching
4035- GetProviderModels(providerName string) (*model.ProviderModels, error)
4036- SetProviderModels(models *model.ProviderModels) error
4037- DeleteProviderModels(providerName string) error
4038- Close() error
4039-}
4040-
4041-type DB struct {
4042- db *gorm.DB
4043-}
4044-
4045-// NewCacheDB creates a new CacheDB instance using SQLite.
4046-func NewCacheDB(verbose bool) (CacheDB, error) {
4047- userConfigDir, err := os.UserConfigDir()
4048- if err != nil {
4049- return nil, fmt.Errorf("failed to get user config directory: %w", err)
4050- }
4051-
4052- ipswConfigDir := filepath.Join(userConfigDir, "ipsw")
4053- if err := os.MkdirAll(ipswConfigDir, 0750); err != nil {
4054- return nil, fmt.Errorf("failed to create ipsw config directory '%s': %w", ipswConfigDir, err)
4055- }
4056-
4057- dbPath := filepath.Join(ipswConfigDir, cacheDBName)
4058-
4059- gormDB, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
4060- SkipDefaultTransaction: true,
4061- TranslateError: true,
4062- Logger: logger.Default.LogMode(logger.Silent),
4063- })
4064- if err != nil {
4065- return nil, fmt.Errorf("failed to connect to AI cache sqlite database at '%s': %w", dbPath, err)
4066- }
4067- if verbose {
4068- gormDB.Logger = logger.Default.LogMode(logger.Info)
4069- }
4070-
4071- if err := gormDB.AutoMigrate(
4072- &model.ChatResponse{},
4073- &model.CopilotToken{},
4074- &model.ProviderModels{},
4075- ); err != nil {
4076- sqlDB, closeErr := gormDB.DB()
4077- if closeErr == nil {
4078- _ = sqlDB.Close()
4079- }
4080- return nil, fmt.Errorf("failed to auto-migrate AI cache schema: %w", err)
4081- }
4082-
4083- return &DB{db: gormDB}, nil
4084-}
4085-
4086-/*
4087- Chat Response Caching Methods
4088-*/
4089-
4090-// Get fetches an entry from the AI cache by parameters.
4091-func (d *DB) Get(uuid, provider, modelName, prompt string, temperature, topP float64) (*model.ChatResponse, error) {
4092- var entry model.ChatResponse
4093- // Query by all relevant parameters using the composite index
4094- if err := d.db.Where(&model.ChatResponse{
4095- UUID: uuid,
4096- Provider: provider,
4097- LLMModel: modelName,
4098- Prompt: prompt,
4099- Temperature: temperature,
4100- TopP: topP,
4101- }).First(&entry).Error; err != nil {
4102- if errors.Is(err, gorm.ErrRecordNotFound) {
4103- return nil, gorm.ErrRecordNotFound // Return gorm's error directly for cache miss
4104- }
4105- return nil, fmt.Errorf("failed to get AI cache entry: %w", err)
4106- }
4107- return &entry, nil
4108-}
4109-
4110-// Set stores an entry in the AI cache.
4111-func (d *DB) Set(entry *model.ChatResponse) error {
4112- if err := d.db.Create(entry).Error; err != nil {
4113- return fmt.Errorf("failed to set AI cache entry: %w", err)
4114- }
4115- return nil
4116-}
4117-
4118-// Close closes the database connection.
4119-func (d *DB) Close() error {
4120- sqlDB, err := d.db.DB()
4121- if err != nil {
4122- return fmt.Errorf("failed to get underlying DB instance: %w", err)
4123- }
4124- return sqlDB.Close()
4125-}
4126-
4127-/*
4128- Copilot Token Caching Methods
4129-*/
4130-
4131-// GetToken retrieves a cached Copilot token.
4132-func (d *DB) GetToken(key string) (*model.CopilotToken, error) {
4133- var token model.CopilotToken
4134- if err := d.db.Where("key = ?", key).First(&token).Error; err != nil {
4135- if errors.Is(err, gorm.ErrRecordNotFound) {
4136- return nil, model.ErrNotFound // Use your defined ErrNotFound from model pkg
4137- }
4138- return nil, fmt.Errorf("failed to get copilot token from cache: %w", err)
4139- }
4140- return &token, nil
4141-}
4142-
4143-// SetToken stores or updates a Copilot token in the cache.
4144-func (d *DB) SetToken(tokenToSet *model.CopilotToken) error {
4145- // Attempt to find by key, then update or create.
4146- // Using Assign to either update the existing record or create a new one if not found.
4147- if err := d.db.Where(model.CopilotToken{Key: tokenToSet.Key}).Assign(tokenToSet).FirstOrCreate(tokenToSet).Error; err != nil {
4148- return fmt.Errorf("failed to set copilot token in cache: %w", err)
4149- }
4150- return nil
4151-}
4152-
4153-/*
4154- Provider Models Caching Methods
4155-*/
4156-
4157-// GetProviderModels retrieves cached models for a provider.
4158-func (d *DB) GetProviderModels(provider string) (*model.ProviderModels, error) {
4159- var pm model.ProviderModels
4160- if err := d.db.Where("provider = ?", provider).First(&pm).Error; err != nil {
4161- if errors.Is(err, gorm.ErrRecordNotFound) {
4162- return nil, model.ErrNotFound
4163- }
4164- return nil, fmt.Errorf("failed to get provider models from cache for %s: %w", provider, err)
4165- }
4166- return &pm, nil
4167-}
4168-
4169-// SetProviderModels stores or updates cached models for a provider.
4170-func (d *DB) SetProviderModels(modelsToSet *model.ProviderModels) error {
4171- // This will update the ModelsJSON if the provider already exists, or insert if it doesn't
4172- if err := d.db.Clauses(clause.OnConflict{
4173- Columns: []clause.Column{{Name: "provider"}},
4174- DoUpdates: clause.AssignmentColumns([]string{"models_json", "updated_at"}),
4175- }).Create(modelsToSet).Error; err != nil {
4176- return fmt.Errorf("failed to upsert provider models in cache for %s: %w", modelsToSet.Provider, err)
4177- }
4178- return nil
4179-}
4180-
4181-// DeleteProviderModels removes cached models for a provider.
4182-func (d *DB) DeleteProviderModels(provider string) error {
4183- if err := d.db.Where("provider = ?", provider).Delete(&model.ProviderModels{}).Error; err != nil {
4184- if errors.Is(err, gorm.ErrRecordNotFound) {
4185- return nil // Not an error if it's already gone or never existed
4186- }
4187- return fmt.Errorf("failed to delete provider models from cache for %s: %w", provider, err)
4188- }
4189- return nil
4190-}
4191diff --git a/internal/model/ai/cache.go b/internal/model/ai/cache.go
4192deleted file mode 100644
4193index a9622ce13..000000000
4194--- a/internal/model/ai/cache.go
4195+++ /dev/null
4196@@ -1,38 +0,0 @@
4197-package ai
4198-
4199-import (
4200- "errors"
4201-
4202- "gorm.io/gorm"
4203-)
4204-
4205-var ErrNotFound = errors.New("not found")
4206-
4207-// ChatResponse represents a cached AI response in the database.
4208-type ChatResponse struct {
4209- gorm.Model
4210- UUID string `gorm:"index"`
4211- Provider string `gorm:"index"`
4212- LLMModel string `gorm:"index"`
4213- Prompt string `gorm:"index"`
4214- Temperature float64 `gorm:"index"`
4215- TopP float64 `gorm:"index"`
4216- // Stream bool // Decided to not cache streaming responses for now, or cache only the full aggregated response.
4217- Response string
4218-}
4219-
4220-// CopilotToken represents a cached Copilot API token.
4221-type CopilotToken struct {
4222- gorm.Model
4223- Key string `gorm:"uniqueIndex"` // A unique key to identify this token, e.g., "active_copilot_token"
4224- Token string // The API token (tokenResponse.Token)
4225- ExpiresAt int64 // Unix timestamp (tokenResponse.ExpiresAt)
4226- TokenResponseJSON string // JSON marshaled full tokenResponse
4227-}
4228-
4229-// ProviderModels represents cached AI provider models in the database.
4230-type ProviderModels struct {
4231- gorm.Model
4232- Provider string `gorm:"uniqueIndex"` // e.g., "copilot", "openai"
4233- ModelsJSON string // JSON marshaled map[string]string of model name to model ID
4234-}
4235--
42362.54.0
4237