cmd/sketch: add skaband-supported qwen

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: scbc3e984d79ceda6k
diff --git a/cmd/sketch/main.go b/cmd/sketch/main.go
index c4c978e..36f1d1b 100644
--- a/cmd/sketch/main.go
+++ b/cmd/sketch/main.go
@@ -127,6 +127,7 @@
 		fmt.Println("- opus (Claude 4 Opus)")
 		fmt.Println("- sonnet (Claude 4 Sonnet)")
 		fmt.Println("- gemini (Google Gemini 2.5 Pro)")
+		fmt.Println("- qwen (Qwen3-Coder)")
 		for _, name := range oai.ListModels() {
 			note := ""
 			if name != "gpt4.1" {
@@ -141,10 +142,10 @@
 		return dumpDistFilesystem(flagArgs.dumpDist)
 	}
 
-	// Only Claude and Gemini have skaband support, for now.
-	hasSkabandSupport := flagArgs.modelName == "gemini" || ant.IsClaudeModel(flagArgs.modelName)
+	// Only Claude, Gemini, and Qwen have skaband support, for now.
+	hasSkabandSupport := flagArgs.modelName == "gemini" || ant.IsClaudeModel(flagArgs.modelName) || flagArgs.modelName == "qwen"
 	if !hasSkabandSupport && flagArgs.skabandAddr != "" {
-		return fmt.Errorf("only claude and gemini are supported by skaband, use -skaband-addr='' for other models")
+		return fmt.Errorf("only claude, gemini, and qwen are supported by skaband, use -skaband-addr='' for other models")
 	}
 
 	if err := flagArgs.experimentFlag.Process(); err != nil {
@@ -529,6 +530,7 @@
 		SkabandAddr:       flags.skabandAddr,
 		Model:             flags.modelName,
 		ModelURL:          spec.modelURL,
+		OAIModelName:      spec.oaiModelName,
 		ModelAPIKey:       spec.apiKey,
 		Path:              cwd,
 		GitUsername:       flags.gitUsername,
@@ -583,8 +585,12 @@
 	if err != nil && os.Getenv("SKETCH_MODEL_URL") != "" {
 		return err
 	}
-
-	return setupAndRunAgent(ctx, flags, modelURL, apiKey, pubKey, true, logFile)
+	spec := modelSpec{
+		modelURL:     modelURL,
+		oaiModelName: os.Getenv("SKETCH_OAI_MODEL_NAME"),
+		apiKey:       apiKey,
+	}
+	return setupAndRunAgent(ctx, flags, spec, pubKey, true, logFile)
 }
 
 // runInUnsafeMode handles execution on the host machine without Docker.
@@ -594,12 +600,13 @@
 	if err != nil {
 		return err
 	}
-	return setupAndRunAgent(ctx, flags, spec.modelURL, spec.apiKey, pubKey, false, logFile)
+	return setupAndRunAgent(ctx, flags, spec, pubKey, false, logFile)
 }
 
 type modelSpec struct {
-	modelURL string
-	apiKey   string
+	modelURL     string
+	oaiModelName string // the OpenAI model name, if applicable; this varies even for the same model by provider
+	apiKey       string
 }
 
 // resolveModel logs in to skaband (as appropriate) and resolves the flags to a model URL and API key.
@@ -608,7 +615,7 @@
 	if err != nil {
 		return modelSpec{}, "", err
 	}
-	pubKey, modelURL, apiKey, err := skabandclient.Login(os.Stdout, privKey, flags.skabandAddr, flags.sessionID, flags.modelName)
+	pubKey, modelURL, oaiModelName, apiKey, err := skabandclient.Login(os.Stdout, privKey, flags.skabandAddr, flags.sessionID, flags.modelName)
 	if err != nil {
 		return modelSpec{}, "", err
 	}
@@ -625,12 +632,12 @@
 		}
 	}
 
-	return modelSpec{modelURL: modelURL, apiKey: apiKey}, pubKey, nil
+	return modelSpec{modelURL: modelURL, oaiModelName: oaiModelName, apiKey: apiKey}, pubKey, nil
 }
 
 // setupAndRunAgent handles the common logic for setting up and running the agent
 // in both container and unsafe modes.
-func setupAndRunAgent(ctx context.Context, flags CLIFlags, modelURL, apiKey, pubKey string, inInsideSketch bool, logFile *os.File) error {
+func setupAndRunAgent(ctx context.Context, flags CLIFlags, spec modelSpec, pubKey string, inInsideSketch bool, logFile *os.File) error {
 	// Kick off a version/upgrade check early.
 	// If the results come back quickly enough,
 	// we can show them as part of the startup UI.
@@ -644,7 +651,7 @@
 	// This is needed for MCP server authentication placeholder replacement
 	if pubKey != "" {
 		os.Setenv("SKETCH_PUB_KEY", pubKey)
-		os.Setenv("SKETCH_MODEL_API_KEY", apiKey)
+		os.Setenv("SKETCH_MODEL_API_KEY", spec.apiKey)
 	}
 
 	wd, err := os.Getwd()
@@ -661,7 +668,7 @@
 		}
 	}
 
-	llmService, err := selectLLMService(nil, flags, modelURL, apiKey)
+	llmService, err := selectLLMService(nil, flags, spec)
 	if err != nil {
 		return fmt.Errorf("failed to initialize LLM service: %w", err)
 	}
@@ -823,7 +830,7 @@
 			}
 		}
 		if agentConfig.SkabandClient != nil {
-			sessionSecret := apiKey
+			sessionSecret := spec.apiKey
 			go agentConfig.SkabandClient.DialAndServeLoop(ctx, flags.sessionID, sessionSecret, srv, connectFn)
 		}
 	}
@@ -930,29 +937,29 @@
 // If modelName is "gemini", it uses the Gemini 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, flags CLIFlags, modelURL, apiKey string) (llm.Service, error) {
+func selectLLMService(client *http.Client, flags CLIFlags, spec modelSpec) (llm.Service, error) {
 	if ant.IsClaudeModel(flags.modelName) {
-		if apiKey == "" {
+		if spec.apiKey == "" {
 			return nil, fmt.Errorf("no anthropic api key provided, set %s", ant.APIKeyEnv)
 		}
 		return &ant.Service{
 			HTTPC:   client,
-			URL:     modelURL,
-			APIKey:  apiKey,
+			URL:     spec.modelURL,
+			APIKey:  spec.apiKey,
 			DumpLLM: flags.dumpLLM,
 			Model:   ant.ClaudeModelName(flags.modelName),
 		}, nil
 	}
 
 	if flags.modelName == "gemini" {
-		if apiKey == "" {
+		if spec.apiKey == "" {
 			return nil, fmt.Errorf("no gemini api key provided, set %s", gem.GeminiAPIKeyEnv)
 		}
 		return &gem.Service{
 			HTTPC:   client,
-			URL:     modelURL,
+			URL:     spec.modelURL,
 			Model:   gem.DefaultModel,
-			APIKey:  apiKey,
+			APIKey:  spec.apiKey,
 			DumpLLM: flags.dumpLLM,
 		}, nil
 	}
@@ -963,16 +970,22 @@
 	}
 
 	// Verify we have an API key, if necessary.
-	apiKey = cmp.Or(os.Getenv(model.APIKeyEnv), flags.llmAPIKey)
+	apiKey := cmp.Or(spec.apiKey, os.Getenv(model.APIKeyEnv), flags.llmAPIKey)
 	if apiKey == "" {
 		return nil, fmt.Errorf("missing API key for %s model, set %s environment variable", model.UserName, model.APIKeyEnv)
 	}
 
+	// Respect skaband-provided model name, if present.
+	if spec.oaiModelName != "" {
+		model.ModelName = spec.oaiModelName
+	}
+
 	return &oai.Service{
-		HTTPC:   client,
-		Model:   model,
-		APIKey:  apiKey,
-		DumpLLM: flags.dumpLLM,
+		HTTPC:    client,
+		Model:    model,
+		ModelURL: spec.modelURL,
+		APIKey:   apiKey,
+		DumpLLM:  flags.dumpLLM,
 	}, nil
 }
 
diff --git a/cmd/sketch/main_test.go b/cmd/sketch/main_test.go
index 10ca1e0..c377542 100644
--- a/cmd/sketch/main_test.go
+++ b/cmd/sketch/main_test.go
@@ -65,7 +65,7 @@
 	}
 
 	// This should fail due to missing API key, but should still set the environment variable
-	err := setupAndRunAgent(context.TODO(), flags, "", "", testPubKey, false, nil)
+	err := setupAndRunAgent(context.TODO(), flags, modelSpec{}, testPubKey, false, nil)
 
 	// Check that the environment variable was set correctly
 	if os.Getenv("SKETCH_PUB_KEY") != testPubKey {
@@ -98,7 +98,7 @@
 	}
 
 	// This should fail due to missing API key, but should not change the environment variable
-	err := setupAndRunAgent(context.TODO(), flags, "", "", "", false, nil)
+	err := setupAndRunAgent(context.TODO(), flags, modelSpec{}, "", false, nil)
 
 	// Check that the environment variable was not changed
 	if os.Getenv("SKETCH_PUB_KEY") != "existing-value" {
diff --git a/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
index 535ad65..80830e8 100644
--- a/dockerimg/dockerimg.go
+++ b/dockerimg/dockerimg.go
@@ -46,6 +46,9 @@
 	// ModelURL is the URL of the LLM service.
 	ModelURL string
 
+	// OAIModelName is the openai model name of the LLM model to use.
+	OAIModelName string
+
 	// ModelAPIKey is the API key for LLM service.
 	ModelAPIKey string
 
@@ -550,6 +553,9 @@
 	if config.ModelURL != "" {
 		cmdArgs = append(cmdArgs, "-e", "SKETCH_MODEL_URL="+config.ModelURL)
 	}
+	if config.OAIModelName != "" {
+		cmdArgs = append(cmdArgs, "-e", "SKETCH_OAI_MODEL_NAME="+config.OAIModelName)
+	}
 	if config.SketchPubKey != "" {
 		cmdArgs = append(cmdArgs, "-e", "SKETCH_PUB_KEY="+config.SketchPubKey)
 	}
diff --git a/llm/oai/oai.go b/llm/oai/oai.go
index 8a450c4..c561095 100644
--- a/llm/oai/oai.go
+++ b/llm/oai/oai.go
@@ -215,6 +215,13 @@
 		URL:       FireworksURL,
 		APIKeyEnv: FireworksAPIKeyEnv,
 	}
+
+	// Qwen is a skaband-specific model name for Qwen3-Coder
+	// Provider details (URL and APIKeyEnv) are handled by skaband
+	Qwen = Model{
+		UserName:  "qwen",
+		ModelName: "qwen", // skaband will map this to the actual provider model
+	}
 )
 
 // Service provides chat completions.
@@ -223,6 +230,7 @@
 	HTTPC     *http.Client // defaults to http.DefaultClient if nil
 	APIKey    string       // optional, if not set will try to load from env var
 	Model     Model        // defaults to DefaultModel if zero value
+	ModelURL  string       // optional, overrides Model.URL
 	MaxTokens int          // defaults to DefaultMaxTokens if zero
 	Org       string       // optional - organization ID
 	DumpLLM   bool         // whether to dump request/response text to files for debugging; defaults to false
@@ -255,6 +263,7 @@
 	MistralMedium,
 	DevstralSmall,
 	Qwen3CoderFireworks,
+	Qwen,
 }
 
 // ListModels returns a list of all available models with their user-friendly names.
@@ -635,6 +644,8 @@
 		return 200000 // 200k for O3 models
 	case "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct":
 		return 256000 // 256k native context for Qwen3-Coder
+	case "qwen":
+		return 256000 // 256k native context for Qwen3-Coder
 	default:
 		// Default for unknown models
 		return 128000
@@ -649,8 +660,8 @@
 
 	// TODO: do this one during Service setup? maybe with a constructor instead?
 	config := openai.DefaultConfig(s.APIKey)
-	if model.URL != "" {
-		config.BaseURL = model.URL
+	if modelURLOverride := cmp.Or(s.ModelURL, model.URL); modelURLOverride != "" {
+		config.BaseURL = modelURLOverride
 	}
 	if s.Org != "" {
 		config.OrgID = s.Org
diff --git a/skabandclient/skabandclient.go b/skabandclient/skabandclient.go
index 2560889..e53adc8 100644
--- a/skabandclient/skabandclient.go
+++ b/skabandclient/skabandclient.go
@@ -192,16 +192,16 @@
 // Login connects to skaband and authenticates the user.
 // If skabandAddr is empty, it returns the public key without contacting a server.
 // It is the caller's responsibility to set the API URL and key in this case.
-func Login(stdout io.Writer, privKey ed25519.PrivateKey, skabandAddr, sessionID, model string) (pubKey, apiURL, apiKey string, err error) {
+func Login(stdout io.Writer, privKey ed25519.PrivateKey, skabandAddr, sessionID, model string) (pubKey, apiURL, oaiModelName, apiKey string, err error) {
 	sig := ed25519.Sign(privKey, []byte(sessionID))
 	pubKey = hex.EncodeToString(privKey.Public().(ed25519.PublicKey))
 	if skabandAddr == "" {
-		return pubKey, "", "", nil
+		return pubKey, "", "", "", nil
 	}
 
 	req, err := http.NewRequest("POST", skabandAddr+"/authclient", nil)
 	if err != nil {
-		return "", "", "", err
+		return "", "", "", "", err
 	}
 	req.Header.Set("Public-Key", pubKey)
 	req.Header.Set("Session-ID", sessionID)
@@ -209,25 +209,27 @@
 	req.Header.Set("X-Model", model)
 	resp, err := http.DefaultClient.Do(req)
 	if err != nil {
-		return "", "", "", fmt.Errorf("skaband login: %w", err)
+		return "", "", "", "", fmt.Errorf("skaband login: %w", err)
 	}
 	apiURL = resp.Header.Get("X-API-URL")
 	apiKey = resp.Header.Get("X-API-Key")
+	oaiModelName = resp.Header.Get("X-OAI-Model")
 	defer resp.Body.Close()
 	_, err = io.Copy(stdout, resp.Body)
 	if err != nil {
-		return "", "", "", fmt.Errorf("skaband login: %w", err)
+		return "", "", "", "", fmt.Errorf("skaband login: %w", err)
 	}
 	if resp.StatusCode != 200 {
-		return "", "", "", fmt.Errorf("skaband login failed: %d", resp.StatusCode)
+		return "", "", "", "", fmt.Errorf("skaband login failed: %d", resp.StatusCode)
 	}
 	if apiURL == "" {
-		return "", "", "", fmt.Errorf("skaband returned no api url")
+		return "", "", "", "", fmt.Errorf("skaband returned no api url")
 	}
 	if apiKey == "" {
-		return "", "", "", fmt.Errorf("skaband returned no api key")
+		return "", "", "", "", fmt.Errorf("skaband returned no api key")
 	}
-	return pubKey, apiURL, apiKey, nil
+	fmt.Printf("skaband login successful, API URL: %s, API Key: %s\n", apiURL, apiKey)
+	return pubKey, apiURL, oaiModelName, apiKey, nil
 }
 
 func DefaultKeyPath(skabandAddr string) string {