all: support openai-compatible models

The support is rather minimal at this point:
Only hard-coded models, only -unsafe, only -skabandaddr="".

The "shared" LLM package is strongly Claude-flavored.

We can fix all of this and more over time, if we are inspired to.
(Maybe we'll switch to https://github.com/maruel/genai?)

The goal for now is to get the rough structure in place.
I've rebased and rebuilt this more times than I care to remember.
diff --git a/cmd/go2ts/go2ts.go b/cmd/go2ts/go2ts.go
index f7d773f..0a175a9 100644
--- a/cmd/go2ts/go2ts.go
+++ b/cmd/go2ts/go2ts.go
@@ -12,7 +12,7 @@
 	"os"
 
 	"go.skia.org/infra/go/go2ts"
-	"sketch.dev/ant"
+	"sketch.dev/llm"
 	"sketch.dev/loop"
 	"sketch.dev/loop/server"
 )
@@ -54,7 +54,7 @@
 		loop.AgentMessage{},
 		loop.GitCommit{},
 		loop.ToolCall{},
-		ant.Usage{},
+		llm.Usage{},
 		server.State{},
 	)
 
diff --git a/cmd/sketch/main.go b/cmd/sketch/main.go
index e99d354..183aef0 100644
--- a/cmd/sketch/main.go
+++ b/cmd/sketch/main.go
@@ -17,11 +17,15 @@
 	"strings"
 	"time"
 
+	"sketch.dev/llm"
+	"sketch.dev/llm/oai"
+
 	"github.com/richardlehane/crock32"
-	"sketch.dev/ant"
 	"sketch.dev/browser"
 	"sketch.dev/dockerimg"
 	"sketch.dev/httprr"
+	"sketch.dev/llm/ant"
+	"sketch.dev/llm/conversation"
 	"sketch.dev/loop"
 	"sketch.dev/loop/server"
 	"sketch.dev/skabandclient"
@@ -40,10 +44,8 @@
 // run is the main entry point that parses flags and dispatches to the appropriate
 // execution path based on whether we're running in a container or not.
 func run() error {
-	// Parse command-line flags
 	flagArgs := parseCLIFlags()
 
-	// Handle version flag early
 	if flagArgs.version {
 		bi, ok := debug.ReadBuildInfo()
 		if ok {
@@ -52,6 +54,26 @@
 		return nil
 	}
 
+	if flagArgs.listModels {
+		fmt.Println("Available models:")
+		fmt.Println("- claude (default, uses Anthropic service)")
+		for _, name := range oai.ListModels() {
+			note := ""
+			if name != "gpt4.1" {
+				note = " (not recommended)"
+			}
+			fmt.Printf("- %s%s\n", name, note)
+		}
+		return nil
+	}
+
+	// For now, only Claude is supported in container mode.
+	// TODO: finish support--thread through API keys, add server support
+	isClaude := flagArgs.modelName == "claude" || flagArgs.modelName == ""
+	if !isClaude && (!flagArgs.unsafe || flagArgs.skabandAddr != "") {
+		return fmt.Errorf("only -model=claude is supported in safe mode right now, use -unsafe -skaband-addr=''")
+	}
+
 	// Add a global "session_id" to all logs using this context.
 	// A "session" is a single full run of the agent.
 	ctx := skribe.ContextWithAttr(context.Background(), slog.String("session_id", flagArgs.sessionID))
@@ -120,6 +142,8 @@
 	maxDollars        float64
 	oneShot           bool
 	prompt            string
+	modelName         string
+	listModels        bool
 	verbose           bool
 	version           bool
 	workingDir        string
@@ -152,6 +176,8 @@
 	flag.Float64Var(&flags.maxDollars, "max-dollars", 5.0, "maximum dollars the agent should spend per turn, 0 to disable limit")
 	flag.BoolVar(&flags.oneShot, "one-shot", false, "exit after the first turn without termui")
 	flag.StringVar(&flags.prompt, "prompt", "", "prompt to send to sketch")
+	flag.StringVar(&flags.modelName, "model", "claude", "model to use (e.g. claude, gpt4.1)")
+	flag.BoolVar(&flags.listModels, "list-models", false, "list all available models and exit")
 	flag.BoolVar(&flags.verbose, "verbose", false, "enable verbose output")
 	flag.BoolVar(&flags.version, "version", false, "print the version and exit")
 	flag.StringVar(&flags.workingDir, "C", "", "when set, change to this directory before running")
@@ -318,19 +344,25 @@
 		client = rr.Client()
 	}
 
-	// Get current working directory
 	wd, err := os.Getwd()
 	if err != nil {
 		return err
 	}
 
-	// Create and configure the agent
+	llmService, err := selectLLMService(client, flags.modelName, antURL, apiKey)
+	if err != nil {
+		return fmt.Errorf("failed to initialize LLM service: %w", err)
+	}
+	budget := conversation.Budget{
+		MaxResponses: flags.maxIterations,
+		MaxWallTime:  flags.maxWallTime,
+		MaxDollars:   flags.maxDollars,
+	}
+
 	agentConfig := loop.AgentConfig{
 		Context:           ctx,
-		AntURL:            antURL,
-		APIKey:            apiKey,
-		HTTPC:             client,
-		Budget:            ant.Budget{MaxResponses: flags.maxIterations, MaxWallTime: flags.maxWallTime, MaxDollars: flags.maxDollars},
+		Service:           llmService,
+		Budget:            budget,
 		GitUsername:       flags.gitUsername,
 		GitEmail:          flags.gitEmail,
 		SessionID:         flags.sessionID,
@@ -507,3 +539,37 @@
 	}
 	return strings.TrimSpace(string(out))
 }
+
+// selectLLMService creates an LLM service based on the specified model name.
+// If modelName is empty or "claude", it uses the Anthropic service.
+// Otherwise, it tries to use the OpenAI service with the specified model.
+// Returns an error if the model name is not recognized or if required configuration is missing.
+func selectLLMService(client *http.Client, modelName string, antURL, apiKey string) (llm.Service, error) {
+	if modelName == "" || modelName == "claude" {
+		if apiKey == "" {
+			return nil, fmt.Errorf("missing ANTHROPIC_API_KEY")
+		}
+		return &ant.Service{
+			HTTPC:  client,
+			URL:    antURL,
+			APIKey: apiKey,
+		}, nil
+	}
+
+	model := oai.ModelByUserName(modelName)
+	if model == nil {
+		return nil, fmt.Errorf("unknown model '%s', use -list-models to see available models", modelName)
+	}
+
+	// Verify we have an API key, if necessary.
+	apiKey = os.Getenv(model.APIKeyEnv)
+	if model.APIKeyEnv != "" && apiKey == "" {
+		return nil, fmt.Errorf("missing API key for %s model, set %s environment variable", model.UserName, model.APIKeyEnv)
+	}
+
+	return &oai.Service{
+		HTTPC:  client,
+		Model:  *model,
+		APIKey: apiKey,
+	}, nil
+}