diff --git a/README.md b/README.md index 24c4e82..9075b84 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,10 @@ See [How It Works](#how-it-works) for the full flow. | `hpphub launch claude` | Install + login + launch Claude Code with HPP | | `hpphub launch claude --persist` | Save HPP settings to shell profile (run `claude` directly after) | | `hpphub launch claude --unpersist` | Remove HPP settings from shell profile | -| `hpphub login` | Log in to HPP Hub | +| `hpphub login` | Log in to HPP Hub (syncs OpenClaw credentials if configured) | +| `hpphub login --force` | Re-login with a different account | | `hpphub logout` | Log out | -| `hpphub whoami` | Show current login status | +| `hpphub whoami` | Show current login status (email, hub, router, API key) | | `hpphub models` | List available models with pricing | | `hpphub setup telegram` | Connect a Telegram bot to OpenClaw | | `hpphub uninstall` | Remove hpphub and its configuration | @@ -97,7 +98,10 @@ Not logged in. Starting login flow... ``` ✓ Logged in as you@example.com - ✓ API key saved + ✓ Hub: https://hub.hpp.io + ✓ Router: https://router.hpp.io/llm/v1 + ✓ API key: ...abcd + ✓ OpenClaw HPP credentials synced # when ~/.openclaw/openclaw.json exists Available models: 1. anthropic/claude-sonnet-4-6 ($3.00/$15.00 per M tokens) @@ -122,6 +126,10 @@ $ hpphub launch openclaw ✓ OpenClaw detected ✓ Logged in as you@example.com + ✓ Hub: https://hub.hpp.io + ✓ Router: https://router.hpp.io/llm/v1 + ✓ API key: ...abcd + ✓ OpenClaw HPP credentials synced # apiKey/baseUrl + vision backfill ✓ HPP already configured (model: hpp/openai/gpt-5-mini) ✓ Gateway already running ``` @@ -145,7 +153,9 @@ Not logged in. Starting login flow... Your code: WXYZ-5678 # same Device Code Flow as OpenClaw Browser opened. Enter the code and authorize. ✓ Logged in as you@example.com - ✓ API key saved + ✓ Hub: https://hub.hpp.io + ✓ Router: https://router.hpp.io/llm/v1 + ✓ API key: ...abcd ✓ Model: claude-sonnet-4-6 Starting Claude Code with HPP... @@ -196,6 +206,24 @@ Other channels (WhatsApp, Discord, Slack, etc.): openclaw configure --section channels ``` +### OpenClaw — Image / vision in WebChat + +OpenClaw requires custom provider models to declare image support (`input: ["text", "image"]`). Without it, WebChat accepts image attachments but the gateway sends text-only requests to the model. + +`hpphub launch openclaw` (and `hpphub login` when OpenClaw is already configured) automatically: + +- Sets `"input": ["text", "image"]` on vision-capable models (e.g. `gpt-4o`, `claude-sonnet-4-6`) +- Skips text-only models (`ollama/*`, `gpt-image-*`, `o3-mini`) +- Syncs `models.providers.hpp` / `hpp-anthropic` `apiKey` and `baseUrl` from the current `hpphub` login + +Use a vision model for image chat (e.g. `hpphub launch openclaw --model openai/gpt-4o`), then restart the gateway if it is already running: + +```bash +openclaw gateway restart +``` + +**Switching accounts:** run `hpphub logout`, then `hpphub login` (or `hpphub login --force`). OpenClaw credentials are updated automatically — you do not need to re-run `launch openclaw` unless you want to change the default model. + ## Configuration **CLI config** — `~/.hpphub/config.json`: @@ -207,7 +235,7 @@ openclaw configure --section channels } ``` -**OpenClaw config** — `~/.openclaw/openclaw.json` (auto-generated by `hpphub launch openclaw`) +**OpenClaw config** — `~/.openclaw/openclaw.json` (auto-generated by `hpphub launch openclaw`; API keys and vision `input` metadata are kept in sync on `hpphub login` and `hpphub launch openclaw`) ## Windows Notes diff --git a/cmd/hpphub/main.go b/cmd/hpphub/main.go index fe1ab2e..ac92c35 100644 --- a/cmd/hpphub/main.go +++ b/cmd/hpphub/main.go @@ -103,10 +103,9 @@ func loginCmd() *cobra.Command { } fmt.Println() - fmt.Printf(" ✓ Logged in as %s\n", token.Email) - if token.APIKey != "" { - suffix := token.APIKey[len(token.APIKey)-4:] - fmt.Printf(" ✓ API key saved: ...%s\n", suffix) + openclaw.PrintAccountSummary(cfg) + if err := openclaw.SyncOpenClawCredentialsWithMessage(cfg); err != nil { + fmt.Printf(" ⚠ %s\n", err) } return nil @@ -159,6 +158,14 @@ func whoamiCmd() *cobra.Command { return nil } fmt.Printf("Logged in as %s\n", cfg.Email) + fmt.Printf("Hub: %s\n", cfg.GetHubURL()) + if cfg.BaseURL != "" { + fmt.Printf("Router: %s\n", cfg.BaseURL) + } + if cfg.APIKey != "" { + suffix := cfg.APIKey[len(cfg.APIKey)-4:] + fmt.Printf("API key: ...%s\n", suffix) + } return nil }, } diff --git a/internal/openclaw/account.go b/internal/openclaw/account.go new file mode 100644 index 0000000..b57e484 --- /dev/null +++ b/internal/openclaw/account.go @@ -0,0 +1,22 @@ +package openclaw + +import ( + "fmt" + + "github.com/hpp-io/hpphub-cli/internal/config" +) + +// PrintAccountSummary prints the active HPP account details after login. +func PrintAccountSummary(cfg *config.Config) { + fmt.Printf(" ✓ Logged in as %s\n", cfg.Email) + if cfg.GetHubURL() != "" { + fmt.Printf(" ✓ Hub: %s\n", cfg.GetHubURL()) + } + if cfg.BaseURL != "" { + fmt.Printf(" ✓ Router: %s\n", cfg.BaseURL) + } + if cfg.APIKey != "" { + suffix := cfg.APIKey[len(cfg.APIKey)-4:] + fmt.Printf(" ✓ API key: ...%s\n", suffix) + } +} diff --git a/internal/openclaw/credentials.go b/internal/openclaw/credentials.go new file mode 100644 index 0000000..f368797 --- /dev/null +++ b/internal/openclaw/credentials.go @@ -0,0 +1,108 @@ +package openclaw + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/hpp-io/hpphub-cli/internal/config" +) + +func openClawConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".openclaw", "openclaw.json"), nil +} + +// SyncOpenClawCredentials updates HPP provider API keys and base URLs in +// ~/.openclaw/openclaw.json to match the current hpphub login. Also backfills +// vision input metadata on supported models when missing. +func SyncOpenClawCredentials(cfg *config.Config) (bool, error) { + if cfg == nil || !cfg.IsLoggedIn() { + return false, nil + } + if !isHPPConfigured() { + return false, nil + } + + configPath, err := openClawConfigPath() + if err != nil { + return false, err + } + data, err := os.ReadFile(configPath) + if err != nil { + return false, err + } + + var clawConfig map[string]interface{} + if err := json.Unmarshal(data, &clawConfig); err != nil { + return false, err + } + + models, ok := clawConfig["models"].(map[string]interface{}) + if !ok { + return false, nil + } + providers, ok := models["providers"].(map[string]interface{}) + if !ok { + return false, nil + } + + changed := false + anthropicBaseURL := strings.Replace(cfg.BaseURL, "/llm/v1", "/v1", 1) + + if hpp, ok := providers["hpp"].(map[string]interface{}); ok { + if hpp["apiKey"] != cfg.APIKey { + hpp["apiKey"] = cfg.APIKey + changed = true + } + if hpp["baseUrl"] != cfg.BaseURL { + hpp["baseUrl"] = cfg.BaseURL + changed = true + } + } + + if anthropic, ok := providers["hpp-anthropic"].(map[string]interface{}); ok { + if anthropic["apiKey"] != cfg.APIKey { + anthropic["apiKey"] = cfg.APIKey + changed = true + } + if anthropic["baseUrl"] != anthropicBaseURL { + anthropic["baseUrl"] = anthropicBaseURL + changed = true + } + } + + if backfillVisionInputs(providers) { + changed = true + } + + if !changed { + return false, nil + } + + output, err := json.MarshalIndent(clawConfig, "", " ") + if err != nil { + return false, err + } + if err := os.WriteFile(configPath, output, 0600); err != nil { + return false, err + } + return true, nil +} + +// SyncOpenClawCredentialsWithMessage syncs credentials and prints a status line. +func SyncOpenClawCredentialsWithMessage(cfg *config.Config) error { + updated, err := SyncOpenClawCredentials(cfg) + if err != nil { + return fmt.Errorf("failed to sync OpenClaw credentials: %w", err) + } + if updated { + fmt.Println(" ✓ OpenClaw HPP credentials synced") + } + return nil +} diff --git a/internal/openclaw/launch.go b/internal/openclaw/launch.go index e8a74e5..52ee5c9 100644 --- a/internal/openclaw/launch.go +++ b/internal/openclaw/launch.go @@ -60,12 +60,11 @@ func Launch(modelFlag string, configOnly bool, hubURL string) error { return err } } else { - fmt.Printf(" ✓ Logged in as %s\n", cfg.Email) + PrintAccountSummary(cfg) } - if cfg.APIKey != "" { - suffix := cfg.APIKey[len(cfg.APIKey)-4:] - fmt.Printf(" ✓ API key: ...%s\n", suffix) + if err := SyncOpenClawCredentialsWithMessage(cfg); err != nil { + fmt.Printf(" ⚠ %s\n", err) } // Step 3: Check if already configured @@ -305,7 +304,10 @@ func RunLogin(cfg *config.Config) error { return fmt.Errorf("failed to save config: %w", err) } - fmt.Printf(" ✓ Logged in as %s\n", token.Email) + PrintAccountSummary(cfg) + if err := SyncOpenClawCredentialsWithMessage(cfg); err != nil { + return err + } return nil } @@ -367,17 +369,10 @@ func configureOpenClaw(cfg *config.Config, model string) error { var openaiModels []map[string]interface{} var anthropicModels []map[string]interface{} for _, m := range apiModels { - entry := map[string]interface{}{ - "id": m.ID, - "name": m.ID, - } if strings.HasPrefix(m.ID, "anthropic/") { - // Strip prefix for Anthropic native API (model ID without "anthropic/") - entry["id"] = strings.TrimPrefix(m.ID, "anthropic/") - entry["name"] = m.ID - anthropicModels = append(anthropicModels, entry) + anthropicModels = append(anthropicModels, buildModelEntry(m)) } else { - openaiModels = append(openaiModels, entry) + openaiModels = append(openaiModels, buildModelEntry(m)) } } diff --git a/internal/openclaw/models.go b/internal/openclaw/models.go new file mode 100644 index 0000000..da64396 --- /dev/null +++ b/internal/openclaw/models.go @@ -0,0 +1,95 @@ +package openclaw + +import ( + "strings" + + "github.com/hpp-io/hpphub-cli/internal/api" +) + +var visionInput = []interface{}{"text", "image"} + +// modelSupportsVision reports whether a router model ID accepts image input in +// OpenClaw WebChat. Custom providers default to text-only unless input is set. +func modelSupportsVision(modelID string) bool { + if modelID == "" { + return false + } + lower := strings.ToLower(modelID) + if strings.Contains(lower, "ollama/") { + return false + } + if strings.Contains(lower, "gpt-image") || strings.Contains(lower, "dall-e") { + return false + } + if strings.Contains(lower, "o3-mini") { + return false + } + if strings.Contains(lower, "claude-") { + return true + } + if strings.Contains(lower, "gpt-4o") || strings.Contains(lower, "gpt-4.1") { + return true + } + if strings.Contains(lower, "gpt-5") { + return true + } + if strings.Contains(lower, "/o3") || strings.HasSuffix(lower, "o3") { + return true + } + if strings.Contains(lower, "o4-mini") { + return true + } + return false +} + +func buildModelEntry(m api.Model) map[string]interface{} { + entry := map[string]interface{}{ + "id": m.ID, + "name": m.ID, + } + if strings.HasPrefix(m.ID, "anthropic/") { + entry["id"] = strings.TrimPrefix(m.ID, "anthropic/") + entry["name"] = m.ID + } + if modelSupportsVision(m.ID) { + entry["input"] = visionInput + } + return entry +} + +func applyVisionInputToEntry(entry map[string]interface{}) bool { + id, _ := entry["id"].(string) + name, _ := entry["name"].(string) + if !modelSupportsVision(id) && !modelSupportsVision(name) { + return false + } + if existing, ok := entry["input"].([]interface{}); ok && len(existing) >= 2 { + return false + } + entry["input"] = visionInput + return true +} + +func backfillVisionInputs(providers map[string]interface{}) bool { + changed := false + for _, providerName := range []string{"hpp", "hpp-anthropic"} { + provider, ok := providers[providerName].(map[string]interface{}) + if !ok { + continue + } + models, ok := provider["models"].([]interface{}) + if !ok { + continue + } + for _, raw := range models { + entry, ok := raw.(map[string]interface{}) + if !ok { + continue + } + if applyVisionInputToEntry(entry) { + changed = true + } + } + } + return changed +} diff --git a/internal/openclaw/models_test.go b/internal/openclaw/models_test.go new file mode 100644 index 0000000..0dfdfb1 --- /dev/null +++ b/internal/openclaw/models_test.go @@ -0,0 +1,151 @@ +package openclaw + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/hpp-io/hpphub-cli/internal/api" + "github.com/hpp-io/hpphub-cli/internal/config" +) + +func TestModelSupportsVision(t *testing.T) { + tests := []struct { + id string + want bool + }{ + {"openai/gpt-4o", true}, + {"openai/gpt-4.1-mini", true}, + {"openai/gpt-5-nano", true}, + {"openai/o3", true}, + {"openai/o3-mini", false}, + {"openai/o4-mini", true}, + {"openai/gpt-image-1", false}, + {"ollama/gpt-oss:120b", false}, + {"anthropic/claude-sonnet-4-6", true}, + {"claude-sonnet-4-6", true}, + } + for _, tt := range tests { + if got := modelSupportsVision(tt.id); got != tt.want { + t.Errorf("modelSupportsVision(%q) = %v, want %v", tt.id, got, tt.want) + } + } +} + +func TestBuildModelEntry(t *testing.T) { + vision := buildModelEntry(api.Model{ID: "openai/gpt-4o"}) + if vision["input"] == nil { + t.Fatal("expected vision input on gpt-4o") + } + + textOnly := buildModelEntry(api.Model{ID: "ollama/gpt-oss:120b"}) + if textOnly["input"] != nil { + t.Fatal("did not expect input on ollama model") + } + + anthropic := buildModelEntry(api.Model{ID: "anthropic/claude-sonnet-4-6"}) + if anthropic["id"] != "claude-sonnet-4-6" { + t.Fatalf("id = %v", anthropic["id"]) + } + if anthropic["input"] == nil { + t.Fatal("expected vision input on claude model") + } +} + +func TestSyncOpenClawCredentials(t *testing.T) { + home := t.TempDir() + openclawDir := filepath.Join(home, ".openclaw") + if err := os.MkdirAll(openclawDir, 0700); err != nil { + t.Fatal(err) + } + + initial := map[string]interface{}{ + "models": map[string]interface{}{ + "providers": map[string]interface{}{ + "hpp": map[string]interface{}{ + "apiKey": "hpph_oldkey", + "baseUrl": "https://router.hpp.io/llm/v1", + "models": []interface{}{ + map[string]interface{}{ + "id": "openai/gpt-4o", + "name": "openai/gpt-4o", + }, + }, + }, + "hpp-anthropic": map[string]interface{}{ + "apiKey": "hpph_oldkey", + "baseUrl": "https://router.hpp.io/v1", + "models": []interface{}{ + map[string]interface{}{ + "id": "claude-sonnet-4-6", + "name": "anthropic/claude-sonnet-4-6", + }, + }, + }, + }, + }, + } + data, err := json.MarshalIndent(initial, "", " ") + if err != nil { + t.Fatal(err) + } + configPath := filepath.Join(openclawDir, "openclaw.json") + if err := os.WriteFile(configPath, data, 0600); err != nil { + t.Fatal(err) + } + + t.Setenv("HOME", home) + + cfg := &config.Config{ + APIKey: "hpph_newkey", + BaseURL: "https://router.hpp.io/llm/v1", + Email: "new@example.com", + } + + updated, err := SyncOpenClawCredentials(cfg) + if err != nil { + t.Fatal(err) + } + if !updated { + t.Fatal("expected credentials sync to update file") + } + + out, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + var saved map[string]interface{} + if err := json.Unmarshal(out, &saved); err != nil { + t.Fatal(err) + } + providers := saved["models"].(map[string]interface{})["providers"].(map[string]interface{}) + hpp := providers["hpp"].(map[string]interface{}) + if hpp["apiKey"] != "hpph_newkey" { + t.Fatalf("hpp apiKey = %v", hpp["apiKey"]) + } + anthropic := providers["hpp-anthropic"].(map[string]interface{}) + if anthropic["apiKey"] != "hpph_newkey" { + t.Fatalf("anthropic apiKey = %v", anthropic["apiKey"]) + } + + models := hpp["models"].([]interface{}) + entry := models[0].(map[string]interface{}) + if entry["input"] == nil { + t.Fatal("expected vision input backfill on gpt-4o") + } +} + +func TestSyncOpenClawCredentialsNoOpWhenNotConfigured(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + cfg := &config.Config{APIKey: "hpph_test", BaseURL: "https://router.hpp.io/llm/v1"} + updated, err := SyncOpenClawCredentials(cfg) + if err != nil { + t.Fatal(err) + } + if updated { + t.Fatal("expected no update when openclaw is not configured") + } +}