Repository: blacktop/ipsw
Pull request #704: feat: add `--llm openrouter` provider to `ipsw diass --dec` commands
Author: login: kormax
Created: 2025-05-08T15:14:45Z | Merged: 2025-05-10T16:39:24Z

This PR adds support for [openrouter.ai](https://openrouter.ai) LLM provider, which acts as a wrapper for many other models, which is useful, as one account can be used to try out many paid-for models without having to load up balance on each respective platform.

The implementation is basic, stuff like streaming is TODO.

I've added "Title" and "Referrer" headers to Openrouter requests so that it would be easier to track usage through their console, but if there are any project/privacy concerns, those can be removed.


Diff:
diff --git a/cmd/ipsw/cmd/dyld/dyld_disass.go b/cmd/ipsw/cmd/dyld/dyld_disass.go
index b9f6ec6b46..ccc660ac90 100644
--- a/cmd/ipsw/cmd/dyld/dyld_disass.go
+++ b/cmd/ipsw/cmd/dyld/dyld_disass.go
@@ -118,7 +118,9 @@ var DisassCmd = &cobra.Command{
 		# Disassemble a function at a virtual address in dyld_shared_cache and do NOT markup analysis (Faster)
 		❯ ipsw dsc disass DSC --vaddr 0x1b19d6940 --quiet
 		# Decompile a function at a virtual address in dyld_shared_cache (via GitHub Copilot)
-		❯ ipsw dsc disass DSC --vaddr 0x1b19d6940 --dec --dec-model "Claude 3.7 Sonnet"`),
+		❯ ipsw dsc disass DSC --vaddr 0x1b19d6940 --dec --dec-model "Claude 3.7 Sonnet"
+		# Decompile a function using OpenRouter to access various models
+		❯ ipsw dsc disass DSC --vaddr 0x1b19d6940 --dec --dec-llm openrouter --dec-model "OpenAI: GPT-4o-mini"`),
 	Args: cobra.ExactArgs(1),
 	ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
 		return getDSCs(toComplete), cobra.ShellCompDirectiveDefault
diff --git a/internal/ai/ai.go b/internal/ai/ai.go
index b47af84617..082895336d 100644
--- a/internal/ai/ai.go
+++ b/internal/ai/ai.go
@@ -13,6 +13,7 @@ import (
 	"github.com/blacktop/ipsw/internal/ai/gemini"
 	"github.com/blacktop/ipsw/internal/ai/ollama"
 	"github.com/blacktop/ipsw/internal/ai/openai"
+	"github.com/blacktop/ipsw/internal/ai/openrouter"
 	db "github.com/blacktop/ipsw/internal/db/ai"
 	model "github.com/blacktop/ipsw/internal/model/ai"
 	"gorm.io/gorm"
@@ -24,6 +25,7 @@ var Providers = []string{
 	"gemini",
 	"ollama",
 	"openai",
+	"openrouter",
 }
 
 type AI interface {
@@ -236,6 +238,14 @@ func NewAI(ctx context.Context, cfg *Config) (AI, error) {
 			TopP:        cfg.TopP,
 			Stream:      cfg.Stream,
 		})
+	case "openrouter":
+		baseAI, err = openrouter.NewOpenRouter(ctx, &openrouter.Config{
+			Prompt:      cfg.Prompt,
+			Model:       cfg.Model,
+			Temperature: cfg.Temperature,
+			TopP:        cfg.TopP,
+			Stream:      cfg.Stream,
+		})
 	default:
 		return nil, fmt.Errorf("unknown AI provider: %s", cfg.Provider)
 	}
diff --git a/internal/ai/openrouter/openrouter.go b/internal/ai/openrouter/openrouter.go
new file mode 100644
index 0000000000..6d57ca0268
--- /dev/null
+++ b/internal/ai/openrouter/openrouter.go
@@ -0,0 +1,225 @@
+package openrouter
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"time"
+
+	"github.com/blacktop/ipsw/internal/ai/utils"
+)
+
+const (
+	openrouterChatCompletionsEndpoint = "https://openrouter.ai/api/v1/chat/completions"
+	openrouterModelsEndpoint          = "https://openrouter.ai/api/v1/models"
+)
+
+// Config holds the configuration for the OpenRouter LLM API client
+type Config struct {
+	Prompt      string  `json:"prompt"`
+	Model       string  `json:"model"`
+	Temperature float64 `json:"temperature"`
+	TopP        float64 `json:"top_p"`
+	Stream      bool    `json:"stream"`
+}
+
+// OpenRouter represents a client for the OpenRouter API
+type OpenRouter struct {
+	ctx    context.Context
+	conf   *Config
+	client *http.Client
+	models map[string]string
+	apiKey string
+}
+
+// chatMessage represents a single message in the conversation
+type chatMessage struct {
+	Role    string `json:"role"`
+	Content string `json:"content"`
+}
+
+// chatRequest is the payload sent to the OpenRouter chat API
+type chatRequest struct {
+	Model       string        `json:"model"`
+	Messages    []chatMessage `json:"messages"`
+	Temperature float64       `json:"temperature"`
+	TopP        float64       `json:"top_p"`
+	Stream      bool          `json:"stream"`
+}
+
+// chatResponse represents the response from the OpenRouter chat API
+type chatResponse struct {
+	ID      string `json:"id"`
+	Object  string `json:"object"`
+	Created int64  `json:"created"`
+	Model   string `json:"model"`
+	Choices []struct {
+		Message struct {
+			Role    string `json:"role"`
+			Content string `json:"content"`
+		} `json:"message"`
+		FinishReason string `json:"finish_reason"`
+	} `json:"choices"`
+	Usage struct {
+		PromptTokens     int `json:"prompt_tokens"`
+		CompletionTokens int `json:"completion_tokens"`
+		TotalTokens      int `json:"total_tokens"`
+	} `json:"usage"`
+}
+
+// modelsResponse represents the response from the OpenRouter models API
+type modelsResponse struct {
+	Data []struct {
+		ID              string  `json:"id"`
+		Name            string  `json:"name"`
+		Description     string  `json:"description"`
+		Context_length  int     `json:"context_length"`
+		PricePrompt     float64 `json:"pricing.prompt"`
+		PriceCompletion float64 `json:"pricing.completion"`
+	} `json:"data"`
+}
+
+// NewOpenRouter creates a new OpenRouter API client
+func NewOpenRouter(ctx context.Context, conf *Config) (*OpenRouter, error) {
+	apiKey := os.Getenv("OPENROUTER_API_KEY")
+	if apiKey == "" {
+		return nil, fmt.Errorf("failed to create OpenRouter client: OPENROUTER_API_KEY environment variable is not set")
+	}
+
+	client := &OpenRouter{
+		ctx:    ctx,
+		conf:   conf,
+		client: &http.Client{Timeout: 300 * time.Second},
+		models: make(map[string]string),
+		apiKey: apiKey,
+	}
+
+	return client, nil
+}
+
+// Models returns the available models from OpenRouter
+func (c *OpenRouter) Models() (map[string]string, error) {
+	if len(c.models) > 0 {
+		return c.models, nil
+	}
+	modelsResponse, err := c.getModels()
+	if err != nil {
+		return nil, fmt.Errorf("openrouter: failed to get models: %w", err)
+	}
+
+	// Populate the models map with the model IDs
+	for _, model := range modelsResponse.Data {
+		c.models[model.Name] = model.ID
+	}
+
+	return c.models, nil
+}
+
+// SetModel sets the model to use for the OpenRouter client
+func (c *OpenRouter) SetModel(model string) error {
+	if _, ok := c.models[model]; !ok {
+		return fmt.Errorf("model '%s' not found", model)
+	}
+	c.conf.Model = model
+	return nil
+}
+
+// SetModels sets the available models for the OpenRouter client
+func (c *OpenRouter) SetModels(models map[string]string) (map[string]string, error) {
+	c.models = models
+	return c.models, nil
+}
+
+// getModels retrieves the available models from OpenRouter API
+func (c *OpenRouter) getModels() (*modelsResponse, error) {
+	req, err := http.NewRequestWithContext(c.ctx, "GET", openrouterModelsEndpoint, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create request: %w", err)
+	}
+
+	c.setRequestHeaders(req)
+
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to send request: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(resp.Body)
+		return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
+	}
+
+	var response modelsResponse
+	if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
+		return nil, fmt.Errorf("failed to decode response: %w", err)
+	}
+
+	return &response, nil
+}
+
+// Chat sends a message to the OpenRouter API and returns the response
+func (c *OpenRouter) Chat() (string, error) {
+	reqBody := chatRequest{
+		Model:       c.models[c.conf.Model],
+		Messages:    []chatMessage{{Role: "user", Content: c.conf.Prompt}},
+		Temperature: c.conf.Temperature,
+		TopP:        c.conf.TopP,
+		Stream:      false, // We don't support streaming yet
+	}
+
+	data, err := json.Marshal(reqBody)
+	if err != nil {
+		return "", fmt.Errorf("failed to marshal request: %w", err)
+	}
+
+	req, err := http.NewRequestWithContext(c.ctx, "POST", openrouterChatCompletionsEndpoint, bytes.NewBuffer(data))
+	if err != nil {
+		return "", fmt.Errorf("failed to create request: %w", err)
+	}
+
+	c.setRequestHeaders(req)
+
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return "", fmt.Errorf("failed to send request: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(resp.Body)
+		return "", fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
+	}
+
+	var response chatResponse
+	if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
+		return "", fmt.Errorf("failed to decode response: %w", err)
+	}
+
+	if len(response.Choices) == 0 {
+		return "", fmt.Errorf("no response choices returned")
+	}
+
+	return utils.Clean(response.Choices[0].Message.Content), nil
+}
+
+// Close implements the ai.AI interface
+func (c *OpenRouter) Close() error {
+	return nil // No specific resources to close for OpenRouter client
+}
+
+// setRequestHeaders sets the common headers for OpenRouter API requests
+func (c *OpenRouter) setRequestHeaders(req *http.Request) {
+	req.Header.Set("Authorization", "Bearer "+c.apiKey)
+	req.Header.Set("Content-Type", "application/json")
+	req.Header.Set("Accept", "application/json")
+	// Set HTTP-Referer and X-Title headers only if OPENROUTER_CLIENT_TITLE is set
+	if clientTitle := os.Getenv("OPENROUTER_CLIENT_TITLE"); clientTitle != "" {
+		req.Header.Set("HTTP-Referer", "https://github.com/blacktop/ipsw")
+		req.Header.Set("X-Title", clientTitle)
+	}
+}
diff --git a/www/docs/guides/decompiler.md b/www/docs/guides/decompiler.md
index e02e5b4a15..49484fae33 100644
--- a/www/docs/guides/decompiler.md
+++ b/www/docs/guides/decompiler.md
@@ -7,13 +7,14 @@ description: Using the AI decompiler.
 
 ## Requirements
 
-There are currently 5 supported LLM providers
+There are currently 6 supported LLM providers
 
 - Github Copilot
 - OpenAI
 - Claude (Anthropic)
 - Gemini (Google AI)
 - Ollama (local LLMs)
+- OpenRouter (API access to multiple models)
 
 ### Github Copilot
 
@@ -65,6 +66,15 @@ Install [ollama](https://ollama.com) and download a few popular models *(maybe `
 I personally have NEVER gotten usable results from a local LLM, but maybe some of you have BEEFY machines and can run the 200B+ parameter models, my laptop would just self-destruct if I tried 💻🔥
 :::
 
+### OpenRouter (API access to multiple models)
+
+OpenRouter provides API access to a variety of models from different providers (OpenAI, Anthropic, Google, Meta, etc.) through a unified API.
+
+You must signup for and buy some API credits from https://openrouter.ai/ first. Then generate an API key and put it in your environment as `OPENROUTER_API_KEY` and `ipsw` will auto-detect this and use it via the `--dec-llm openrouter` provider.
+
+Optionally, set `OPENROUTER_CLIENT_TITLE` to identify your application in requests when reviewing credit usage activity.
+
+
 ## Getting Started
 
 There are 2 `ipsw` disassemblers:


Reviews:
user: login: blacktop
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-08T21:04:17Z
user: login: blacktop
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-08T21:06:01Z
user: login: blacktop
state: COMMENTED
author_association: OWNER
submitted_at: 2025-05-08T21:07:58Z

Review comments:
diff_hunk: @@ -0,0 +1,243 @@
+package openrouter
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"time"
+
+	"github.com/blacktop/ipsw/internal/ai/utils"
+)
+
+const (
+	openrouterChatCompletionsEndpoint = "https://openrouter.ai/api/v1/chat/completions"
+	openrouterModelsEndpoint          = "https://openrouter.ai/api/v1/models"
+)
+
+// Config holds the configuration for the OpenRouter LLM API client
+type Config struct {
+	Prompt      string  `json:"prompt"`
+	Model       string  `json:"model"`
+	Temperature float64 `json:"temperature"`
+	TopP        float64 `json:"top_p"`
+	Stream      bool    `json:"stream"`
+}
+
+// OpenRouter represents a client for the OpenRouter API
+type OpenRouter struct {
+	ctx    context.Context
+	conf   *Config
+	client *http.Client
+	models map[string]string
+}
+
+// chatMessage represents a single message in the conversation
+type chatMessage struct {
+	Role    string `json:"role"`
+	Content string `json:"content"`
+}
+
+// chatRequest is the payload sent to the OpenRouter chat API
+type chatRequest struct {
+	Model       string        `json:"model"`
+	Messages    []chatMessage `json:"messages"`
+	Temperature float64       `json:"temperature"`
+	TopP        float64       `json:"top_p"`
+	Stream      bool          `json:"stream"`
+}
+
+// chatResponse represents the response from the OpenRouter chat API
+type chatResponse struct {
+	ID      string `json:"id"`
+	Object  string `json:"object"`
+	Created int64  `json:"created"`
+	Model   string `json:"model"`
+	Choices []struct {
+		Message struct {
+			Role    string `json:"role"`
+			Content string `json:"content"`
+		} `json:"message"`
+		FinishReason string `json:"finish_reason"`
+	} `json:"choices"`
+	Usage struct {
+		PromptTokens     int `json:"prompt_tokens"`
+		CompletionTokens int `json:"completion_tokens"`
+		TotalTokens      int `json:"total_tokens"`
+	} `json:"usage"`
+}
+
+// modelsResponse represents the response from the OpenRouter models API
+type modelsResponse struct {
+	Data []struct {
+		ID              string  `json:"id"`
+		Name            string  `json:"name"`
+		Description     string  `json:"description"`
+		Context_length  int     `json:"context_length"`
+		PricePrompt     float64 `json:"pricing.prompt"`
+		PriceCompletion float64 `json:"pricing.completion"`
+	} `json:"data"`
+}
+
+// NewOpenRouter creates a new OpenRouter API client
+func NewOpenRouter(ctx context.Context, conf *Config) (*OpenRouter, error) {
+	client := &OpenRouter{
+		ctx:    ctx,
+		conf:   conf,
+		client: &http.Client{Timeout: 300 * time.Second},
+		models: make(map[string]string),
+	}
+
+	// Verify API key is available
+	_, err := client.getAPIKey()
path: internal/ai/openrouter/openrouter.go
user: login: blacktop
body: smol nit: I usually like to follow the style

if err := thing(); err != nil {
    return fmt.Errorf()
}
created_at: 2025-05-08T21:04:17Z
author_association: OWNER
diff_hunk: @@ -65,6 +66,17 @@ Install [ollama](https://ollama.com) and download a few popular models *(maybe `
 I personally have NEVER gotten usable results from a local LLM, but maybe some of you have BEEFY machines and can run the 200B+ parameter models, my laptop would just self-destruct if I tried 💻🔥
 :::
 
+### OpenRouter (API access to multiple models)
+
+OpenRouter provides API access to a variety of models from different providers (OpenAI, Anthropic, Google, Meta, etc.) through a unified API.
+
+1. Create an account at https://openrouter.ai/
+2. Create an API key at https://openrouter.ai/keys
+3. Set the API key in your environment as `OPENROUTER_API_KEY`
+4. Use the provider with `--dec-llm openrouter`
+
+This gives you flexibility to access various models without needing individual accounts with each provider.
+
path: www/docs/guides/decompiler.md
user: login: blacktop
body: I would mention something about the OPENROUTER_CLIENT_TITLE here as well
created_at: 2025-05-08T21:06:01Z
author_association: OWNER
diff_hunk: @@ -0,0 +1,243 @@
+package openrouter
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"time"
+
+	"github.com/blacktop/ipsw/internal/ai/utils"
+)
+
+const (
+	openrouterChatCompletionsEndpoint = "https://openrouter.ai/api/v1/chat/completions"
+	openrouterModelsEndpoint          = "https://openrouter.ai/api/v1/models"
+)
+
+// Config holds the configuration for the OpenRouter LLM API client
+type Config struct {
+	Prompt      string  `json:"prompt"`
+	Model       string  `json:"model"`
+	Temperature float64 `json:"temperature"`
+	TopP        float64 `json:"top_p"`
+	Stream      bool    `json:"stream"`
+}
+
+// OpenRouter represents a client for the OpenRouter API
+type OpenRouter struct {
+	ctx    context.Context
+	conf   *Config
+	client *http.Client
+	models map[string]string
+}
+
+// chatMessage represents a single message in the conversation
+type chatMessage struct {
+	Role    string `json:"role"`
+	Content string `json:"content"`
+}
+
+// chatRequest is the payload sent to the OpenRouter chat API
+type chatRequest struct {
+	Model       string        `json:"model"`
+	Messages    []chatMessage `json:"messages"`
+	Temperature float64       `json:"temperature"`
+	TopP        float64       `json:"top_p"`
+	Stream      bool          `json:"stream"`
+}
+
+// chatResponse represents the response from the OpenRouter chat API
+type chatResponse struct {
+	ID      string `json:"id"`
+	Object  string `json:"object"`
+	Created int64  `json:"created"`
+	Model   string `json:"model"`
+	Choices []struct {
+		Message struct {
+			Role    string `json:"role"`
+			Content string `json:"content"`
+		} `json:"message"`
+		FinishReason string `json:"finish_reason"`
+	} `json:"choices"`
+	Usage struct {
+		PromptTokens     int `json:"prompt_tokens"`
+		CompletionTokens int `json:"completion_tokens"`
+		TotalTokens      int `json:"total_tokens"`
+	} `json:"usage"`
+}
+
+// modelsResponse represents the response from the OpenRouter models API
+type modelsResponse struct {
+	Data []struct {
+		ID              string  `json:"id"`
+		Name            string  `json:"name"`
+		Description     string  `json:"description"`
+		Context_length  int     `json:"context_length"`
+		PricePrompt     float64 `json:"pricing.prompt"`
+		PriceCompletion float64 `json:"pricing.completion"`
+	} `json:"data"`
+}
+
+// NewOpenRouter creates a new OpenRouter API client
+func NewOpenRouter(ctx context.Context, conf *Config) (*OpenRouter, error) {
+	client := &OpenRouter{
+		ctx:    ctx,
+		conf:   conf,
+		client: &http.Client{Timeout: 300 * time.Second},
+		models: make(map[string]string),
+	}
+
+	// Verify API key is available
+	_, err := client.getAPIKey()
path: internal/ai/openrouter/openrouter.go
user: login: blacktop
body: i think client.getAPIKey only needs to be called here and then can be added to the `OpenRouter` structure as a private member
created_at: 2025-05-08T21:07:57Z
author_association: OWNER

PR comments:
user: login: blacktop
created_at: 2025-05-08T17:55:45Z
body: this is a great PR! 

I would rather not advertise that `ipsw` is making the requests. Nothing else in `ipsw` does in fact I usually pretend to be a browser to protect my users privacy.

However, maybe we could use some cURL UA that you'd recognize for your logs as a compromise? 
author_association: OWNER
user: login: kormax
created_at: 2025-05-08T17:59:06Z
body: > this is a great PR!
> 
> I would rather not advertise that `ipsw` is making the requests. Nothing else in `ipsw` does in fact I usually pretend to be a browser to protect my users privacy.
> 
> However, maybe we could use some cURL UA that you'd recognize for your logs as a compromise?

I think we can do this through environment variables. If OPENROUTER_CLIENT_TITLE is set, it is sent as the name of the client for tracking in their console.
Otherwise, no tracking headers are sent.
author_association: CONTRIBUTOR
user: login: blacktop
created_at: 2025-05-08T18:03:43Z
body: Ya that's a great option 
author_association: OWNER
user: login: kormax
created_at: 2025-05-08T20:04:14Z
body: Done
author_association: CONTRIBUTOR
user: login: kormax
created_at: 2025-05-10T08:39:44Z
body: Added new comments, moved API key to the struct.
author_association: CONTRIBUTOR
user: login: blacktop
created_at: 2025-05-10T16:38:58Z
body: LGTM
author_association: OWNER