Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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)
Expand All @@ -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
```
Expand All @@ -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...
Expand Down Expand Up @@ -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`:
Expand All @@ -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

Expand Down
15 changes: 11 additions & 4 deletions cmd/hpphub/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
},
}
Expand Down
22 changes: 22 additions & 0 deletions internal/openclaw/account.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
108 changes: 108 additions & 0 deletions internal/openclaw/credentials.go
Original file line number Diff line number Diff line change
@@ -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
}
23 changes: 9 additions & 14 deletions internal/openclaw/launch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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))
}
}

Expand Down
95 changes: 95 additions & 0 deletions internal/openclaw/models.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading