From d99ff2961dc008ef52d1939e748b406c900bb3cc Mon Sep 17 00:00:00 2001 From: Quandale Dingle <39318730+ethanperrine@users.noreply.github.com> Date: Mon, 22 Jun 2026 00:12:28 -0400 Subject: [PATCH] Add PicoClaw provider/auth UI and model testing Rework PicoClaw model configuration into a full provider/auth/provider-model UI and add server-side support for testing and OAuth orchestration. Changes include: - New HTTP routes for model config, model test, and auth status/login/logout/callback in the router. - Added OAuth orchestration that drives the picoclaw binary device-code flow without exposing secrets to the frontend; stores non-secret auth hints in a sidecar file and exposes availability/authenticated flags. - Implemented a Model Test endpoint that runs a minimal prompt against configured providers, classifies outcomes (auth, model, endpoint, network, parser, etc.), and returns a redacted result with a server log reference. - Persist provider/auth/model selection to a non-secret .nanokvm-model.json sidecar and ensure API keys are written only to .security.yml (0600). Preserving unrelated top-level keys when reading/writing .security.yml. - Introduced typed model config handling (validation of provider/auth/method, key management semantics: set/keep/clear) and robust save/apply logic. - Added build-deploy.sh for cross-compiling and deploying the server to NanoKVM devices. - Frontend and i18n/jotai updates to support the new UI and flows. Security: API keys are never returned in API responses or logs; OAuth tokens remain inside the picoclaw binary. Several new files and server handlers implement these behaviors and error codes for auth/model-test flows. --- CHANGELOG.md | 13 + build-deploy.sh | 112 +++++ server/router/picoclaw.go | 11 + server/service/picoclaw/auth.go | 395 +++++++++++++++ server/service/picoclaw/config.go | 10 +- server/service/picoclaw/config_files.go | 81 ++- server/service/picoclaw/errors.go | 5 +- server/service/picoclaw/model_config.go | 214 +++++++- server/service/picoclaw/model_connection.go | 359 ++++++++++++++ server/service/picoclaw/model_meta.go | 213 ++++++++ server/service/picoclaw/oauth_login.go | 266 ++++++++++ server/service/picoclaw/providers.go | 194 ++++++++ server/service/picoclaw/runtime_handlers.go | 8 +- server/service/picoclaw/service.go | 2 +- server/service/picoclaw/types.go | 36 +- web/src/api/picoclaw.ts | 46 +- web/src/i18n/locales/en.ts | 84 +++- web/src/jotai/picoclaw.ts | 69 +++ .../pages/desktop/picoclaw/sidebar-actions.ts | 76 +-- .../desktop/picoclaw/sidebar-model-config.tsx | 465 ++++++++++++++++-- web/src/pages/desktop/picoclaw/sidebar.tsx | 20 +- .../desktop/picoclaw/use-model-config.ts | 334 +++++++++++++ web/src/pages/desktop/picoclaw/use-sidebar.ts | 17 +- 23 files changed, 2824 insertions(+), 206 deletions(-) create mode 100644 build-deploy.sh create mode 100644 server/service/picoclaw/auth.go create mode 100644 server/service/picoclaw/model_connection.go create mode 100644 server/service/picoclaw/model_meta.go create mode 100644 server/service/picoclaw/oauth_login.go create mode 100644 server/service/picoclaw/providers.go create mode 100644 web/src/pages/desktop/picoclaw/use-model-config.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 963a31ba..a6e01148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## Unreleased + +### Features + +* Reworked the PicoClaw "Configure Model" screen into a full provider/model/auth configuration UI: provider presets (OpenAI/Codex, Anthropic, Google Gemini, OpenAI-compatible, local LM Studio/Ollama, custom), per-provider model presets with custom entry, API-key / OAuth / no-auth methods, optional base URL, status badges, and a "Test Model" connection check with classified results +* Added OpenAI/Codex OAuth as a first-class auth option driven by PicoClaw's native device-code flow (`picoclaw auth login --provider openai --device-code`): NanoKVM shows the verification URL and code for the user to authorize on another device, polls until authenticated, stops only the PicoClaw runtime during login to reduce peak memory, and restores the previously-selected default model afterwards +* Extended the PicoClaw runtime status with `provider`, `auth_method`, `oauth_available`, `oauth_authenticated`, `api_key_configured`, and `endpoint_configured` (no secrets exposed) +* Added `GET /api/picoclaw/model/config`, `POST /api/picoclaw/model/test`, and `GET/POST /api/picoclaw/auth/{status,login,logout,callback}` routes + +### Bug Fixes + +* PicoClaw API keys are written only to `.security.yml` (0600) and never returned in API responses or logs; OAuth tokens are kept inside the PicoClaw binary and never exposed to the frontend + ## 2.4.3 (2026-06-09) ### Features diff --git a/build-deploy.sh b/build-deploy.sh new file mode 100644 index 00000000..292715b1 --- /dev/null +++ b/build-deploy.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# build-deploy.sh — cross-compile the NanoKVM server (including the PicoClaw +# integration) for RISC-V and deploy it to a NanoKVM over SSH. +# +# It builds inside the repo's Docker builder image (Go 1.25 + the +# riscv64-unknown-linux-musl toolchain + MaixCDK/libkvm), patches the +# $ORIGIN/dl_lib rpath so the device can find libkvm.so, then scp's the binary +# to /kvmapp/server and restarts the service. +# +# Run from WSL2 or Git Bash with Docker Desktop (the Makefile path won't work in +# raw PowerShell). The first run builds the toolchain image and is slow. +# +# Usage: +# ./build-deploy.sh # build + deploy +# ./build-deploy.sh --build-only # build only, no deploy +# ./build-deploy.sh 192.168.1.50 --user root # custom ssh user +# ./build-deploy.sh 192.168.1.50 --with-web # also build+push the frontend +# +# Env overrides: NANOKVM_HOST, NANOKVM_USER (default root), IMAGE_NAME +# +set -euo pipefail + +# Git Bash / MSYS mangles container-side /paths into Windows paths — disable it. +export MSYS_NO_PATHCONV=1 +export MSYS2_ARG_CONV_EXCL='*' + +IMAGE_NAME="${IMAGE_NAME:-nanokvm-builder}" +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOST="${NANOKVM_HOST:-}" +USER_NAME="${NANOKVM_USER:-root}" +BUILD_ONLY=0 +WITH_WEB=0 + +while [ $# -gt 0 ]; do + case "$1" in + --build-only) BUILD_ONLY=1 ;; + --with-web) WITH_WEB=1 ;; + --user) USER_NAME="${2:?--user needs a value}"; shift ;; + -h|--help) sed -n '/^# Usage:/,/^# Env overrides:/p' "$0" | sed 's/^#\{1,\} \{0,1\}//'; exit 0 ;; + -*) echo "unknown flag: $1" >&2; exit 2 ;; + *) HOST="$1" ;; + esac + shift +done + +log() { printf '\033[1;33m[*]\033[0m %s\n' "$*"; } +ok() { printf '\033[0;32m[ok]\033[0m %s\n' "$*"; } +die() { printf '\033[0;31m[err]\033[0m %s\n' "$*" >&2; exit 1; } + +command -v docker >/dev/null || die "docker not found (need Docker Desktop / docker CLI on PATH)" + +# --------------------------------------------------------------------------- +# 1. Ensure the builder image exists +# --------------------------------------------------------------------------- +if ! docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then + log "Builder image '$IMAGE_NAME' missing — building it (one-time, slow: Go + musl toolchain + libkvm)..." + ( cd "$REPO_DIR" && docker build -t "$IMAGE_NAME" -f docker/Dockerfile . ) + ok "Builder image ready." +else + ok "Builder image '$IMAGE_NAME' present." +fi + +# --------------------------------------------------------------------------- +# 2. Cross-compile + patch rpath +# Runs as root (no -e UID) so we can guarantee patchelf is available; +# build.sh aborts without it. Ownership is restored to the mount owner. +# --------------------------------------------------------------------------- +log "Cross-compiling server/NanoKVM-Server for linux/riscv64..." +docker run --rm -v "$REPO_DIR":/home/build/NanoKVM "$IMAGE_NAME" bash -c ' + set -e + if ! command -v patchelf >/dev/null 2>&1; then + echo "[*] installing patchelf in builder..." + apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq patchelf >/dev/null + fi + cd /home/build/NanoKVM/server + ./build.sh + chown --reference=. NanoKVM-Server 2>/dev/null || true +' +[ -f "$REPO_DIR/server/NanoKVM-Server" ] || die "build produced no binary" +ok "Built server/NanoKVM-Server" + +if [ "$BUILD_ONLY" -eq 1 ]; then + ok "Build-only: binary at server/NanoKVM-Server (not deployed)." + exit 0 +fi + +[ -n "$HOST" ] || die "no NanoKVM host given — pass it as an argument or set NANOKVM_HOST." +SSH_TARGET="$USER_NAME@$HOST" + +# --------------------------------------------------------------------------- +# 3. Deploy +# The device runs /kvmapp/server/NanoKVM-Server (copied to /tmp/server at +# start), so overwriting it is safe and 'restart' picks up the new build. +# Leave /kvmapp/server/dl_lib in place — the rpath points at it. +# --------------------------------------------------------------------------- +log "Copying binary -> $SSH_TARGET:/kvmapp/server/NanoKVM-Server" +scp "$REPO_DIR/server/NanoKVM-Server" "$SSH_TARGET:/kvmapp/server/NanoKVM-Server" \ + || die "scp failed. If it's 'Read-only file system', SSH in and run: mount -o remount,rw /kvmapp" + +if [ "$WITH_WEB" -eq 1 ]; then + command -v pnpm >/dev/null || die "--with-web needs pnpm on PATH" + log "Building web frontend..." + ( cd "$REPO_DIR/web" && pnpm install --frozen-lockfile && pnpm build ) + [ -d "$REPO_DIR/web/dist" ] || die "web build produced no dist/ — check web build output dir" + log "Copying web/dist -> $SSH_TARGET:/kvmapp/server/web/ (verify this path matches your device layout)" + scp -r "$REPO_DIR/web/dist/." "$SSH_TARGET:/kvmapp/server/web/" +fi + +log "Restarting NanoKVM service..." +ssh "$SSH_TARGET" '/etc/init.d/S95nanokvm restart' +ok "Done — new server is live." diff --git a/server/router/picoclaw.go b/server/router/picoclaw.go index ef77c4b5..923ac07f 100644 --- a/server/router/picoclaw.go +++ b/server/router/picoclaw.go @@ -10,6 +10,11 @@ import ( const ( picoclawBasePath = "/api/picoclaw" picoclawModelConfigPath = "/model/config" + picoclawModelTestPath = "/model/test" + picoclawAuthStatusPath = "/auth/status" + picoclawAuthLoginPath = "/auth/login" + picoclawAuthLogoutPath = "/auth/logout" + picoclawAuthCallbackPath = "/auth/callback" picoclawAgentProfilePath = "/agent/profile" picoclawSessionsPath = "/sessions" picoclawSessionByIDPath = "/sessions/:id" @@ -49,7 +54,13 @@ func picoclawRouter(r *gin.Engine) { localAPI.POST(picoclawLoadImagePath, service.LoadImage) localAPI.GET(picoclawRuntimeSessionPath, service.GetRuntimeSession) + frontendAPI.GET(picoclawModelConfigPath, service.GetModelConfig) frontendAPI.POST(picoclawModelConfigPath, service.UpdateModelConfig) + frontendAPI.POST(picoclawModelTestPath, service.TestModel) + frontendAPI.GET(picoclawAuthStatusPath, service.GetAuthStatus) + frontendAPI.POST(picoclawAuthLoginPath, service.StartAuthLogin) + frontendAPI.POST(picoclawAuthLogoutPath, service.AuthLogout) + frontendAPI.POST(picoclawAuthCallbackPath, service.AuthCallback) frontendAPI.POST(picoclawAgentProfilePath, service.UpdateAgentProfile) frontendAPI.GET(picoclawSessionsPath, service.ListSessions) frontendAPI.GET(picoclawSessionByIDPath, service.GetSession) diff --git a/server/service/picoclaw/auth.go b/server/service/picoclaw/auth.go new file mode 100644 index 00000000..087c0a0a --- /dev/null +++ b/server/service/picoclaw/auth.go @@ -0,0 +1,395 @@ +package picoclaw + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// OAuth in NanoKVM is delegated entirely to the installed picoclaw binary: this +// server never handles ChatGPT cookies, browser session tokens, or raw access/ +// refresh tokens. It only orchestrates the binary's own auth subcommand (when it +// exposes one) and records a non-secret "authenticated" hint so the UI can show +// status. If the binary has no auth command, every route below reports a clear +// "unavailable" with the exact command we looked for. + +const ( + picoclawOAuthMetaFileName = ".nanokvm-oauth.json" + picoclawAuthProbeTimeout = 4 * time.Second + picoclawOAuthCapTTL = 60 * time.Second +) + +type picoclawOAuthCapabilityResult struct { + Available bool + Command string + Reason string +} + +var ( + oauthCapMu sync.Mutex + oauthCapCached picoclawOAuthCapabilityResult + oauthCapCheckedAt time.Time + oauthCapProbing bool +) + +// picoclawOAuthCapability reports whether the installed picoclaw binary exposes +// an OAuth login command. The result is cached briefly to keep status polling +// cheap. The binary probe runs WITHOUT holding the mutex, and only one probe runs +// at a time, so a slow/hung binary never serializes unrelated status requests — +// concurrent callers get the last cached value while a single probe refreshes. +func picoclawOAuthCapability() picoclawOAuthCapabilityResult { + oauthCapMu.Lock() + fresh := !oauthCapCheckedAt.IsZero() && time.Since(oauthCapCheckedAt) < picoclawOAuthCapTTL + if fresh || oauthCapProbing { + cached := oauthCapCached + oauthCapMu.Unlock() + return cached + } + oauthCapProbing = true + oauthCapMu.Unlock() + + result := detectPicoclawOAuthCapability() + + oauthCapMu.Lock() + oauthCapCached = result + oauthCapCheckedAt = time.Now() + oauthCapProbing = false + oauthCapMu.Unlock() + return result +} + +func detectPicoclawOAuthCapability() picoclawOAuthCapabilityResult { + installed, err := isPicoclawInstalled() + if err != nil || !installed { + return picoclawOAuthCapabilityResult{ + Available: false, + Reason: "picoclaw is not installed", + } + } + + // Probe the binary for an "auth" subcommand. A zero exit on `auth --help` + // means the subcommand is recognized; we then require it to advertise a + // login/oauth flow before declaring OAuth available. We never guess at flags + // beyond --help, so a missing or unrecognized command reports unavailable + // rather than risking a wrong call. + output, runErr := runPicoclawBinary(picoclawAuthProbeTimeout, "auth", "--help") + if runErr != nil { + return picoclawOAuthCapabilityResult{ + Available: false, + Reason: "the installed picoclaw binary does not expose an `auth` subcommand (looked for `picoclaw auth login`)", + } + } + + combined := strings.ToLower(output) + if strings.Contains(combined, "login") || strings.Contains(combined, "oauth") { + return picoclawOAuthCapabilityResult{Available: true, Command: "picoclaw auth login"} + } + + return picoclawOAuthCapabilityResult{ + Available: false, + Reason: "the installed picoclaw `auth` command does not advertise a login/oauth flow", + } +} + +func runPicoclawBinary(timeout time.Duration, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, picoclawBinaryPath, args...) + output, err := cmd.CombinedOutput() + return strings.TrimSpace(string(output)), err +} + +// --- Non-secret OAuth state store ------------------------------------------- + +type picoclawOAuthState struct { + Providers map[string]picoclawOAuthProviderState `json:"providers,omitempty"` +} + +type picoclawOAuthProviderState struct { + Authenticated bool `json:"authenticated"` + Account string `json:"account,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +func resolvePicoclawOAuthMetaPath() (string, error) { + configPath, err := resolvePicoclawConfigPath() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(configPath), picoclawOAuthMetaFileName), nil +} + +func loadPicoclawOAuthState() picoclawOAuthState { + path, err := resolvePicoclawOAuthMetaPath() + if err != nil { + return picoclawOAuthState{} + } + data, err := os.ReadFile(path) + if err != nil { + return picoclawOAuthState{} + } + var state picoclawOAuthState + if err := json.Unmarshal(data, &state); err != nil { + return picoclawOAuthState{} + } + return state +} + +func savePicoclawOAuthState(state picoclawOAuthState) error { + path, err := resolvePicoclawOAuthMetaPath() + if err != nil { + return err + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +func setProviderOAuthState(provider string, state picoclawOAuthProviderState) error { + all := loadPicoclawOAuthState() + if all.Providers == nil { + all.Providers = map[string]picoclawOAuthProviderState{} + } + all.Providers[provider] = state + return savePicoclawOAuthState(all) +} + +func clearProviderOAuthState(provider string) error { + all := loadPicoclawOAuthState() + if all.Providers == nil { + return nil + } + delete(all.Providers, provider) + return savePicoclawOAuthState(all) +} + +func isProviderOAuthAuthenticated(provider string) bool { + if provider == "" { + return false + } + state := loadPicoclawOAuthState() + entry, ok := state.Providers[provider] + if !ok || !entry.Authenticated { + return false + } + if entry.ExpiresAt != "" { + if expires, err := time.Parse(time.RFC3339, entry.ExpiresAt); err == nil && time.Now().After(expires) { + return false + } + } + return true +} + +// --- HTTP handlers ----------------------------------------------------------- + +type AuthStatusResult struct { + Provider string `json:"provider"` + Available bool `json:"available"` + Authenticated bool `json:"authenticated"` + Status string `json:"status,omitempty"` + LoginURL string `json:"login_url,omitempty"` + UserCode string `json:"user_code,omitempty"` + Account string `json:"account,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + Error string `json:"error,omitempty"` + UnavailableReason string `json:"unavailable_reason,omitempty"` + MissingCommand string `json:"missing_command,omitempty"` +} + +func deviceCodeMissingCommand(provider string) string { + return "picoclaw auth login --provider " + provider + " --device-code" +} + +// mergeLiveLoginState overlays any in-flight/recent device-code login for the +// provider onto a status result so the UI can poll for progress. +func mergeLiveLoginState(result *AuthStatusResult, provider string) { + login := oauthLoginMgr.snapshot() + if login.Provider != provider || login.Status == "" || login.Status == loginStatusIdle { + return + } + result.Status = login.Status + result.LoginURL = login.LoginURL + result.UserCode = login.UserCode + if login.Account != "" && result.Account == "" { + result.Account = login.Account + } + if login.Status == loginStatusFailed { + result.Error = login.Error + } + if login.Status == loginStatusAuthenticated { + result.Authenticated = true + } +} + +func normalizeOAuthProvider(provider string) (string, *PicoclawError) { + provider = strings.TrimSpace(strings.ToLower(provider)) + if provider == "" { + provider = providerOpenAI + } + preset, ok := lookupProviderPreset(provider) + if !ok { + return "", newPicoclawError(CodeInvalidAction, "unknown provider: "+provider) + } + if !providerAllowsAuthMethod(preset, authMethodOAuth) { + return "", newPicoclawError(CodeInvalidAction, "provider does not support OAuth: "+provider) + } + return provider, nil +} + +func (s *Service) GetAuthStatus(c *gin.Context) { + provider, perr := normalizeOAuthProvider(c.Query("provider")) + if perr != nil { + writePicoclawError(c, perr) + return + } + + capability := picoclawOAuthCapability() + result := AuthStatusResult{ + Provider: provider, + Available: capability.Available, + } + if !capability.Available { + result.UnavailableReason = capability.Reason + result.MissingCommand = deviceCodeMissingCommand(provider) + writeSuccess(c, result) + return + } + + result.Authenticated = isProviderOAuthAuthenticated(provider) + if entry, ok := loadPicoclawOAuthState().Providers[provider]; ok { + result.Account = entry.Account + result.ExpiresAt = entry.ExpiresAt + } + mergeLiveLoginState(&result, provider) + writeSuccess(c, result) +} + +type AuthLoginRequest struct { + Provider string `json:"provider"` +} + +type AuthLoginResult struct { + Provider string `json:"provider"` + Available bool `json:"available"` + Status string `json:"status"` + LoginURL string `json:"login_url,omitempty"` + UserCode string `json:"user_code,omitempty"` + RequiresCode bool `json:"requires_code,omitempty"` + UnavailableReason string `json:"unavailable_reason,omitempty"` + MissingCommand string `json:"missing_command,omitempty"` +} + +func (s *Service) StartAuthLogin(c *gin.Context) { + var req AuthLoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid auth login payload")) + return + } + provider, perr := normalizeOAuthProvider(req.Provider) + if perr != nil { + writePicoclawError(c, perr) + return + } + + capability := picoclawOAuthCapability() + if !capability.Available { + // Honest fallback: report unavailability and the exact command we expect + // so the UI can explain precisely what the installed binary is missing. + writeSuccess(c, AuthLoginResult{ + Provider: provider, + Available: false, + Status: "unavailable", + UnavailableReason: capability.Reason, + MissingCommand: deviceCodeMissingCommand(provider), + }) + return + } + + // Drive picoclaw's native device-code login. The runtime is stopped during + // login to cut peak memory and restarted afterwards; the previous default + // model is restored after login completes (see startDeviceCodeLogin). + state := s.startDeviceCodeLogin(provider) + writeSuccess(c, AuthLoginResult{ + Provider: provider, + Available: true, + Status: state.Status, + LoginURL: state.LoginURL, + UserCode: state.UserCode, + RequiresCode: false, + }) +} + +type AuthCallbackRequest struct { + Provider string `json:"provider"` + Code string `json:"code"` + State string `json:"state"` +} + +// AuthCallback is a poll endpoint for the device-code flow. The login itself runs +// to completion in the background after the user authorizes in their browser; +// this just reports the current state (no code needs to be pasted back). +func (s *Service) AuthCallback(c *gin.Context) { + var req AuthCallbackRequest + if err := c.ShouldBindJSON(&req); err != nil { + writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid auth callback payload")) + return + } + provider, perr := normalizeOAuthProvider(req.Provider) + if perr != nil { + writePicoclawError(c, perr) + return + } + + result := AuthStatusResult{ + Provider: provider, + Available: picoclawOAuthCapability().Available, + Authenticated: isProviderOAuthAuthenticated(provider), + } + if entry, ok := loadPicoclawOAuthState().Providers[provider]; ok { + result.Account = entry.Account + result.ExpiresAt = entry.ExpiresAt + } + mergeLiveLoginState(&result, provider) + writeSuccess(c, result) +} + +type AuthLogoutRequest struct { + Provider string `json:"provider"` +} + +func (s *Service) AuthLogout(c *gin.Context) { + var req AuthLogoutRequest + if err := c.ShouldBindJSON(&req); err != nil { + writePicoclawError(c, newPicoclawError(CodeInvalidAction, "invalid auth logout payload")) + return + } + provider, perr := normalizeOAuthProvider(req.Provider) + if perr != nil { + writePicoclawError(c, perr) + return + } + + // Best-effort: ask the binary to drop credentials, then clear our hint and + // any completed in-flight login state. + if picoclawOAuthCapability().Available { + _, _ = runPicoclawBinary(picoclawAuthProbeTimeout, "auth", "logout", "--provider", provider) + } + _ = clearProviderOAuthState(provider) + clearDeviceCodeLogin() + + writeSuccess(c, AuthStatusResult{ + Provider: provider, + Available: picoclawOAuthCapability().Available, + Authenticated: false, + }) +} diff --git a/server/service/picoclaw/config.go b/server/service/picoclaw/config.go index a7c0d923..ef2bd169 100644 --- a/server/service/picoclaw/config.go +++ b/server/service/picoclaw/config.go @@ -148,7 +148,15 @@ func loadPicoclawGatewaySettings() (picoclawGatewaySettings, error) { } settings.TargetModelName = resolvePicoclawTargetModelName(cfg) - if isPicoclawModelConfigured(cfg, doc.security, settings.TargetModelName) { + meta := loadPicoclawModelMeta() + provider := meta.Provider + if provider == "" { + provider = inferProviderFromModel(modelIdentifierForName(cfg, settings.TargetModelName)) + } + oauthAuthenticated := isProviderOAuthAuthenticated(provider) + apiKeyConfigured := modelHasStoredKey(cfg, doc.security, settings.TargetModelName) + authMethod := resolvePicoclawEffectiveAuthMethod(meta, settings.TargetModelName, apiKeyConfigured, oauthAuthenticated) + if isPicoclawModelConfiguredWithAuth(cfg, doc.security, settings.TargetModelName, authMethod, oauthAuthenticated) { settings.ModelConfigured = true settings.ModelName = settings.TargetModelName } diff --git a/server/service/picoclaw/config_files.go b/server/service/picoclaw/config_files.go index 239390f3..c58c7394 100644 --- a/server/service/picoclaw/config_files.go +++ b/server/service/picoclaw/config_files.go @@ -55,6 +55,7 @@ type picoclawConfigDocument struct { config picoclawConfigFile raw map[string]any security picoclawSecurityConfig + securityRaw map[string]any } func loadPicoclawConfigDocument() (*picoclawConfigDocument, error) { @@ -83,6 +84,10 @@ func loadPicoclawConfigDocument() (*picoclawConfigDocument, error) { if err != nil { return nil, err } + securityRaw, err := loadPicoclawSecurityRaw(securityPath) + if err != nil { + return nil, err + } return &picoclawConfigDocument{ configPath: configPath, @@ -90,9 +95,27 @@ func loadPicoclawConfigDocument() (*picoclawConfigDocument, error) { config: cfg, raw: raw, security: security, + securityRaw: securityRaw, }, nil } +// loadPicoclawSecurityRaw reads .security.yml into an untyped map so unrelated +// top-level sections survive a save. Returns nil (not an error) when absent. +func loadPicoclawSecurityRaw(securityPath string) (map[string]any, error) { + data, err := os.ReadFile(securityPath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("failed to read picoclaw security config: %w", err) + } + var raw map[string]any + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to parse picoclaw security config for update: %w", err) + } + return raw, nil +} + func resolvePicoclawConfigPath() (string, error) { home := os.Getenv("PICOCLAW_HOME") if home == "" { @@ -140,19 +163,63 @@ func (d *picoclawConfigDocument) saveConfig() error { } func (d *picoclawConfigDocument) saveSecurity() error { + output, err := d.encodeSecurity() + if err != nil { + return err + } + if err := os.WriteFile(d.securityPath, output, 0o600); err != nil { + return fmt.Errorf("failed to write picoclaw security config: %w", err) + } + return nil +} + +// encodeSecurity serializes the security config while preserving any top-level +// keys present in the original .security.yml that our typed view does not model. +// The typed model_list/channel_list (the only sections we mutate) are written +// back over the preserved raw map; an absent raw map falls back to the typed +// struct (e.g. when the file did not previously exist). +func (d *picoclawConfigDocument) encodeSecurity() ([]byte, error) { + if d.securityRaw == nil { + return marshalSecurityYAML(d.security) + } + + typedMap, err := securityToMap(d.security) + if err != nil { + return nil, err + } + for _, key := range []string{"model_list", "channel_list"} { + if value, ok := typedMap[key]; ok { + d.securityRaw[key] = value + } else { + delete(d.securityRaw, key) + } + } + return marshalSecurityYAML(d.securityRaw) +} + +func securityToMap(sec picoclawSecurityConfig) (map[string]any, error) { + data, err := yaml.Marshal(sec) + if err != nil { + return nil, fmt.Errorf("failed to encode picoclaw security config: %w", err) + } + out := map[string]any{} + if err := yaml.Unmarshal(data, &out); err != nil { + return nil, fmt.Errorf("failed to normalize picoclaw security config: %w", err) + } + return out, nil +} + +func marshalSecurityYAML(value any) ([]byte, error) { var buf bytes.Buffer encoder := yaml.NewEncoder(&buf) encoder.SetIndent(2) - if err := encoder.Encode(d.security); err != nil { - return fmt.Errorf("failed to encode picoclaw security config: %w", err) + if err := encoder.Encode(value); err != nil { + return nil, fmt.Errorf("failed to encode picoclaw security config: %w", err) } if err := encoder.Close(); err != nil { - return fmt.Errorf("failed to finalize picoclaw security config: %w", err) - } - if err := os.WriteFile(d.securityPath, buf.Bytes(), 0o600); err != nil { - return fmt.Errorf("failed to write picoclaw security config: %w", err) + return nil, fmt.Errorf("failed to finalize picoclaw security config: %w", err) } - return nil + return buf.Bytes(), nil } type picoclawSecurityConfig struct { diff --git a/server/service/picoclaw/errors.go b/server/service/picoclaw/errors.go index 7f0c1de9..24aca41f 100644 --- a/server/service/picoclaw/errors.go +++ b/server/service/picoclaw/errors.go @@ -7,7 +7,7 @@ import ( ) const ( - CodePicoclawLockHeld = "AI_LOCK_HELD" + CodePicoclawLockHeld = "AI_LOCK_HELD" CodeScreenshotFailed = "SCREENSHOT_FAILED" CodeScreenshotNoSignal = "SCREENSHOT_NO_SIGNAL" CodeHIDWriteFailed = "HID_WRITE_FAILED" @@ -16,6 +16,9 @@ const ( CodeRuntimeStartFailed = "RUNTIME_START_FAILED" CodeSessionIDMissing = "SESSION_ID_MISSING" CodeSessionIDInvalid = "SESSION_ID_INVALID" + CodeOAuthUnavailable = "OAUTH_UNAVAILABLE" + CodeAuthFailed = "AUTH_FAILED" + CodeModelTestFailed = "MODEL_TEST_FAILED" ) type PicoclawError struct { diff --git a/server/service/picoclaw/model_config.go b/server/service/picoclaw/model_config.go index 4ce174d2..c9496f84 100644 --- a/server/service/picoclaw/model_config.go +++ b/server/service/picoclaw/model_config.go @@ -64,10 +64,47 @@ func securityHasModelAPIKeys(security picoclawSecurityConfig, modelName string) return false } +// ModelConfigResult is the redacted GET /model/config payload. It never returns +// the API key — only whether one exists. +type ModelConfigResult struct { + Provider string `json:"provider"` + ModelName string `json:"model_name"` + ModelIdentifier string `json:"model_identifier"` + APIBase string `json:"api_base"` + AuthMethod string `json:"auth_method"` + ModelConfigured bool `json:"model_configured"` + APIKeyConfigured bool `json:"api_key_configured"` + EndpointConfigured bool `json:"endpoint_configured"` + OAuthAvailable bool `json:"oauth_available"` + OAuthAuthenticated bool `json:"oauth_authenticated"` + AgentProfile string `json:"agent_profile"` + Providers []providerPreset `json:"providers"` +} + +func (s *Service) GetModelConfig(c *gin.Context) { + summary := computePicoclawModelSummary() + writeSuccess(c, ModelConfigResult{ + Provider: summary.Provider, + ModelName: summary.ModelName, + ModelIdentifier: summary.ModelIdentifier, + APIBase: summary.APIBase, + AuthMethod: summary.AuthMethod, + ModelConfigured: summary.ModelConfigured, + APIKeyConfigured: summary.APIKeyConfigured, + EndpointConfigured: summary.EndpointConfigured, + OAuthAvailable: summary.OAuthAvailable, + OAuthAuthenticated: summary.OAuthAuthenticated, + AgentProfile: detectPicoclawAgentProfile(), + Providers: picoclawProviderCatalog, + }) +} + type ModelConfigUpdateRequest struct { - Model string `json:"model"` - APIBase string `json:"api_base"` - APIKey string `json:"api_key"` + Provider string `json:"provider"` + Model string `json:"model"` + APIBase string `json:"api_base"` + APIKey string `json:"api_key"` + AuthMethod string `json:"auth_method"` } func (s *Service) UpdateModelConfig(c *gin.Context) { @@ -80,13 +117,9 @@ func (s *Service) UpdateModelConfig(c *gin.Context) { currentStatus := s.runtime.Get() shouldRestart := currentStatus.Ready || currentStatus.Status == "ready" - modelName, err := updatePicoclawModelConfig( - strings.TrimSpace(req.APIBase), - strings.TrimSpace(req.APIKey), - strings.TrimSpace(req.Model), - ) - if err != nil { - writePicoclawError(c, newPicoclawError(CodeRuntimeUnavailable, err.Error())) + modelName, saveErr := s.saveModelConfig(req) + if saveErr != nil { + writePicoclawError(c, saveErr) return } if err := ensurePicoclawStartupDefaults(); err != nil { @@ -110,26 +143,130 @@ func (s *Service) UpdateModelConfig(c *gin.Context) { writeSuccess(c, gin.H{ "model_name": modelName, - "status": s.runtime.Get(), + "status": withModelMeta(withAgentProfile(s.runtime.Get())), }) } -func updatePicoclawModelConfig(apiBase string, apiKey string, model string) (string, error) { - if apiBase == "" { - return "", fmt.Errorf("model api_base is required") +// saveModelConfig validates the provider/auth/model/endpoint combination and +// persists it. API keys go only to .security.yml; OAuth/no-auth never write a +// key. A non-secret sidecar records the chosen provider + auth method. +func (s *Service) saveModelConfig(req ModelConfigUpdateRequest) (string, *PicoclawError) { + model := strings.TrimSpace(req.Model) + if model == "" { + return "", newPicoclawError(CodeInvalidAction, "model identifier is required") } - if apiKey == "" { - return "", fmt.Errorf("model api_key is required") + + providerID := strings.TrimSpace(strings.ToLower(req.Provider)) + if providerID == "" { + providerID = inferProviderFromModel(model) } - if model == "" { - return "", fmt.Errorf("model identifier is required") + preset, ok := lookupProviderPreset(providerID) + if !ok { + return "", newPicoclawError(CodeInvalidAction, "unsupported provider: "+providerID) } - modelName := extractPicoclawModelName(model) + authMethod := strings.TrimSpace(strings.ToLower(req.AuthMethod)) + if authMethod == "" { + authMethod = authMethodAPIKey + } + if !providerAllowsAuthMethod(preset, authMethod) { + return "", newPicoclawError(CodeInvalidAction, fmt.Sprintf("provider %s does not support auth method %s", providerID, authMethod)) + } + + fullModel := buildModelIdentifier(preset, model) + modelName := extractPicoclawModelName(fullModel) if modelName == "" { - return "", fmt.Errorf("model identifier is required") + return "", newPicoclawError(CodeInvalidAction, "model identifier is required") + } + + apiBase := strings.TrimSpace(req.APIBase) + if apiBase == "" { + apiBase = preset.DefaultAPIBase + } + if apiBase == "" { + return "", newPicoclawError(CodeInvalidAction, "API base URL is required for provider "+providerID) } + doc, err := loadPicoclawConfigDocument() + if err != nil { + return "", newPicoclawError(CodeRuntimeUnavailable, err.Error()) + } + hasExistingKey := modelHasStoredKey(doc.config, doc.security, modelName) + + apiKey := strings.TrimSpace(req.APIKey) + var keyAction modelKeyAction + switch authMethod { + case authMethodAPIKey: + switch { + case apiKey != "": + keyAction = keyActionSet + case hasExistingKey: + keyAction = keyActionKeep + default: + return "", newPicoclawError(CodeInvalidAction, "API key is required for API key authentication") + } + case authMethodOAuth: + capability := picoclawOAuthCapability() + if !capability.Available { + return "", newPicoclawError(CodeOAuthUnavailable, "OAuth is not available: "+capability.Reason) + } + if !isProviderOAuthAuthenticated(providerID) { + return "", newPicoclawError(CodeAuthFailed, "sign in with OAuth before saving (no authenticated session for "+providerID+")") + } + keyAction = keyActionClear + case authMethodNone: + keyAction = keyActionClear + default: + return "", newPicoclawError(CodeInvalidAction, "unsupported auth method: "+authMethod) + } + + savedName, applyErr := applyPicoclawModelConfig(providerID, authMethod, fullModel, modelName, apiBase, apiKey, keyAction) + if applyErr != nil { + return "", newPicoclawError(CodeRuntimeUnavailable, applyErr.Error()) + } + return savedName, nil +} + +type modelKeyAction int + +const ( + keyActionKeep modelKeyAction = iota + keyActionSet + keyActionClear +) + +// deleteModelSecurityKeys removes every .security.yml model_list entry that the +// readers (securityHasModelAPIKeys / resolveModelAPIKey) would treat as a key for +// modelName — the bare name and any ":" form. Returns whether +// anything was removed. +func deleteModelSecurityKeys(security *picoclawSecurityConfig, modelName string) bool { + if security.ModelList == nil { + return false + } + prefix := modelName + ":" + changed := false + for key := range security.ModelList { + if key == modelName || strings.HasPrefix(key, prefix) { + delete(security.ModelList, key) + changed = true + } + } + return changed +} + +func modelHasStoredKey(cfg picoclawConfigFile, security picoclawSecurityConfig, modelName string) bool { + if securityHasModelAPIKeys(security, modelName) { + return true + } + for _, model := range cfg.ModelList { + if strings.TrimSpace(model.ModelName) == modelName && configHasModelAPIKeys(model.APIKey, model.APIKeys) { + return true + } + } + return false +} + +func applyPicoclawModelConfig(provider, authMethod, model, modelName, apiBase, apiKey string, keyAction modelKeyAction) (string, error) { doc, err := loadPicoclawConfigDocument() if err != nil { return "", err @@ -154,6 +291,7 @@ func updatePicoclawModelConfig(apiBase string, apiKey string, model string) (str modelMap["model_name"] = modelName modelMap["model"] = model modelMap["api_base"] = apiBase + // API keys never live in config.json; they belong in .security.yml. delete(modelMap, "api_key") delete(modelMap, "api_keys") modelUpdated = true @@ -186,14 +324,38 @@ func updatePicoclawModelConfig(apiBase string, apiKey string, model string) (str if err := doc.saveConfig(); err != nil { return "", err } - if doc.security.ModelList == nil { - doc.security.ModelList = map[string]picoclawModelSecurityEntry{} - } + securityModelName := indexedModelName(modelListValue, updatedModelIndex, modelName) - doc.security.ModelList[securityModelName] = picoclawModelSecurityEntry{ - APIKeys: []string{apiKey}, + switch keyAction { + case keyActionSet: + if doc.security.ModelList == nil { + doc.security.ModelList = map[string]picoclawModelSecurityEntry{} + } + // Drop any stale entries (bare or other indices) so exactly one canonical + // key remains and cannot be shadowed at read time. + deleteModelSecurityKeys(&doc.security, modelName) + doc.security.ModelList[securityModelName] = picoclawModelSecurityEntry{ + APIKeys: []string{apiKey}, + } + if err := doc.saveSecurity(); err != nil { + return "", err + } + case keyActionClear: + // Remove every key the readers would treat as live for this model. + if deleteModelSecurityKeys(&doc.security, modelName) { + if err := doc.saveSecurity(); err != nil { + return "", err + } + } + case keyActionKeep: + // Leave the existing stored key untouched. } - if err := doc.saveSecurity(); err != nil { + + if err := savePicoclawModelMeta(picoclawModelMeta{ + Provider: provider, + AuthMethod: authMethod, + ModelName: modelName, + }); err != nil { return "", err } diff --git a/server/service/picoclaw/model_connection.go b/server/service/picoclaw/model_connection.go new file mode 100644 index 00000000..b135cddf --- /dev/null +++ b/server/service/picoclaw/model_connection.go @@ -0,0 +1,359 @@ +package picoclaw + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" +) + +// The Configure Model "Test Model" button runs a minimal prompt directly against +// the configured provider so the result can be classified precisely (auth vs. +// model vs. endpoint vs. network). Secrets never appear in the response: at most +// a coarse outcome, a friendly message, the model's short reply, and a server +// log reference. Full provider responses are only logged server-side (redacted). + +const ( + modelTestPrompt = "Reply with exactly OK" + modelTestTimeout = 25 * time.Second +) + +// Test outcomes (kept stable for the frontend i18n mapping). +const ( + testOutcomeSuccess = "success" + testOutcomeAuthError = "auth_error" + testOutcomeInvalidModel = "invalid_model" + testOutcomeInvalidEndpoint = "invalid_endpoint" + testOutcomeNetworkError = "network_error" + testOutcomeParserError = "parser_error" + testOutcomeUnsupported = "unsupported" + testOutcomeNotConfigured = "not_configured" + testOutcomeNotTestable = "oauth_not_testable" + testOutcomeUnknown = "unknown" +) + +type ModelTestRequest struct { + Provider string `json:"provider"` + Model string `json:"model"` + APIBase string `json:"api_base"` + APIKey string `json:"api_key"` + AuthMethod string `json:"auth_method"` +} + +type ModelTestResult struct { + Ok bool `json:"ok"` + Outcome string `json:"outcome"` + Reply string `json:"reply,omitempty"` + StatusCode int `json:"status_code,omitempty"` + LogRef string `json:"log_ref,omitempty"` +} + +func newLogRef() string { + buf := make([]byte, 4) + if _, err := rand.Read(buf); err != nil { + return "0000" + } + return hex.EncodeToString(buf) +} + +func (s *Service) TestModel(c *gin.Context) { + var req ModelTestRequest + _ = c.ShouldBindJSON(&req) // body is optional; falls back to saved config + + logRef := newLogRef() + summary := computePicoclawModelSummary() + + providerID := firstNonEmpty(strings.ToLower(strings.TrimSpace(req.Provider)), summary.Provider) + if providerID == "" { + providerID = providerOpenAICompatible + } + preset, ok := lookupProviderPreset(providerID) + if !ok { + writeSuccess(c, ModelTestResult{Outcome: testOutcomeUnknown, LogRef: logRef}) + return + } + + authMethod := firstNonEmpty(strings.ToLower(strings.TrimSpace(req.AuthMethod)), summary.AuthMethod) + + // Resolve the bare model name to send to the provider. + requestedModel := strings.TrimSpace(req.Model) + var modelName string + if requestedModel != "" { + modelName = extractPicoclawModelName(buildModelIdentifier(preset, requestedModel)) + } else { + modelName = summary.ModelName + } + if modelName == "" { + writeSuccess(c, ModelTestResult{Outcome: testOutcomeNotConfigured, LogRef: logRef}) + return + } + + apiBase := firstNonEmpty(strings.TrimSpace(req.APIBase), summary.APIBase, preset.DefaultAPIBase) + if apiBase == "" { + writeSuccess(c, ModelTestResult{Outcome: testOutcomeInvalidEndpoint, LogRef: logRef}) + return + } + + // OAuth credentials live inside the picoclaw binary; we cannot replay them + // from here, so a direct test is not possible for OAuth setups. Report a + // distinct outcome so the UI explains this honestly rather than implying the + // provider rejected an unsupported tool. + if authMethod == authMethodOAuth { + writeSuccess(c, ModelTestResult{Outcome: testOutcomeNotTestable, LogRef: logRef}) + return + } + + apiKey := strings.TrimSpace(req.APIKey) + if apiKey == "" && authMethod != authMethodNone { + if doc, err := loadPicoclawConfigDocument(); err == nil { + apiKey = resolveModelAPIKey(doc.config, doc.security, modelName) + } + } + if apiKey == "" && authMethod == authMethodAPIKey { + writeSuccess(c, ModelTestResult{Outcome: testOutcomeAuthError, LogRef: logRef}) + return + } + + result := runModelTest(preset.testStyle, apiBase, modelName, apiKey) + result.LogRef = logRef + + // Server-side log only — no key, no raw body. + log.Warnf("[picoclaw model-test %s] provider=%s model=%s style=%s outcome=%s status=%d", + logRef, providerID, modelName, preset.testStyle, result.Outcome, result.StatusCode) + + writeSuccess(c, result) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func resolveModelAPIKey(cfg picoclawConfigFile, security picoclawSecurityConfig, modelName string) string { + if security.ModelList != nil { + if entry, ok := security.ModelList[modelName]; ok && len(entry.APIKeys) > 0 { + return entry.APIKeys[0] + } + prefix := modelName + ":" + for key, entry := range security.ModelList { + if strings.HasPrefix(key, prefix) && len(entry.APIKeys) > 0 { + return entry.APIKeys[0] + } + } + } + for _, model := range cfg.ModelList { + if strings.TrimSpace(model.ModelName) != modelName { + continue + } + if strings.TrimSpace(model.APIKey) != "" { + return model.APIKey + } + if len(model.APIKeys) > 0 { + return model.APIKeys[0] + } + } + return "" +} + +func runModelTest(style, apiBase, modelName, apiKey string) ModelTestResult { + endpoint, body, headers, err := buildModelTestRequest(style, apiBase, modelName, apiKey) + if err != nil { + return ModelTestResult{Outcome: testOutcomeInvalidEndpoint} + } + + ctx, cancel := context.WithTimeout(context.Background(), modelTestTimeout) + defer cancel() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return ModelTestResult{Outcome: testOutcomeInvalidEndpoint} + } + httpReq.Header.Set("Content-Type", "application/json") + for key, value := range headers { + httpReq.Header.Set(key, value) + } + + client := &http.Client{Timeout: modelTestTimeout} + resp, err := client.Do(httpReq) + if err != nil { + return ModelTestResult{Outcome: classifyTransportError(err)} + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + return classifyModelTestResponse(style, resp.StatusCode, respBody) +} + +func buildModelTestRequest(style, apiBase, modelName, apiKey string) (string, []byte, map[string]string, error) { + base := strings.TrimRight(strings.TrimSpace(apiBase), "/") + if base == "" { + return "", nil, nil, fmt.Errorf("empty api base") + } + if parsed, err := url.Parse(base); err != nil || parsed.Host == "" { + return "", nil, nil, fmt.Errorf("invalid api base") + } + + headers := map[string]string{} + + switch style { + case testStyleAnthropic: + headers["x-api-key"] = apiKey + headers["anthropic-version"] = "2023-06-01" + payload := map[string]any{ + "model": modelName, + "max_tokens": 16, + "messages": []map[string]any{ + {"role": "user", "content": modelTestPrompt}, + }, + } + body, _ := json.Marshal(payload) + return base + "/v1/messages", body, headers, nil + + case testStyleGemini: + payload := map[string]any{ + "contents": []map[string]any{ + {"parts": []map[string]any{{"text": modelTestPrompt}}}, + }, + "generationConfig": map[string]any{"maxOutputTokens": 16}, + } + body, _ := json.Marshal(payload) + endpoint := fmt.Sprintf("%s/models/%s:generateContent", base, url.PathEscape(modelName)) + if apiKey != "" { + endpoint += "?key=" + url.QueryEscape(apiKey) + } + return endpoint, body, headers, nil + + default: // testStyleOpenAI and OpenAI-compatible + if apiKey != "" { + headers["Authorization"] = "Bearer " + apiKey + } + payload := map[string]any{ + "model": modelName, + "max_tokens": 16, + "temperature": 0, + "messages": []map[string]any{ + {"role": "user", "content": modelTestPrompt}, + }, + } + body, _ := json.Marshal(payload) + return base + "/chat/completions", body, headers, nil + } +} + +func classifyTransportError(err error) string { + message := strings.ToLower(err.Error()) + switch { + case strings.Contains(message, "no such host"), + strings.Contains(message, "server misbehaving"), + strings.Contains(message, "unsupported protocol"), + strings.Contains(message, "invalid"): + return testOutcomeInvalidEndpoint + default: + return testOutcomeNetworkError + } +} + +func classifyModelTestResponse(style string, statusCode int, body []byte) ModelTestResult { + lowerBody := strings.ToLower(string(body)) + + switch { + case statusCode == http.StatusUnauthorized, statusCode == http.StatusForbidden: + return ModelTestResult{Outcome: testOutcomeAuthError, StatusCode: statusCode} + case statusCode == http.StatusNotFound: + if strings.Contains(lowerBody, "model") { + return ModelTestResult{Outcome: testOutcomeInvalidModel, StatusCode: statusCode} + } + return ModelTestResult{Outcome: testOutcomeInvalidEndpoint, StatusCode: statusCode} + case statusCode == http.StatusBadRequest: + switch { + case strings.Contains(lowerBody, "web_search_preview"), + strings.Contains(lowerBody, "unsupported tool"), + strings.Contains(lowerBody, "not supported"): + return ModelTestResult{Outcome: testOutcomeUnsupported, StatusCode: statusCode} + case strings.Contains(lowerBody, "model"), + strings.Contains(lowerBody, "does not exist"), + strings.Contains(lowerBody, "not found"): + return ModelTestResult{Outcome: testOutcomeInvalidModel, StatusCode: statusCode} + default: + return ModelTestResult{Outcome: testOutcomeUnknown, StatusCode: statusCode} + } + case statusCode >= 200 && statusCode < 300: + reply, ok := extractModelTestReply(style, body) + if !ok { + return ModelTestResult{Outcome: testOutcomeParserError, StatusCode: statusCode} + } + return ModelTestResult{Ok: true, Outcome: testOutcomeSuccess, Reply: reply, StatusCode: statusCode} + case statusCode == http.StatusTooManyRequests: + return ModelTestResult{Outcome: testOutcomeAuthError, StatusCode: statusCode} + default: + return ModelTestResult{Outcome: testOutcomeUnknown, StatusCode: statusCode} + } +} + +// extractModelTestReply pulls the short assistant text from a provider response. +// Returns ok=false when the shape is unrecognizable (a streaming/parser issue). +func extractModelTestReply(style string, body []byte) (string, bool) { + switch style { + case testStyleAnthropic: + var parsed struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } + if err := json.Unmarshal(body, &parsed); err != nil || len(parsed.Content) == 0 { + return "", false + } + return truncateReply(strings.TrimSpace(parsed.Content[0].Text)), true + + case testStyleGemini: + var parsed struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + } + if err := json.Unmarshal(body, &parsed); err != nil || + len(parsed.Candidates) == 0 || len(parsed.Candidates[0].Content.Parts) == 0 { + return "", false + } + return truncateReply(strings.TrimSpace(parsed.Candidates[0].Content.Parts[0].Text)), true + + default: + var parsed struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(body, &parsed); err != nil || len(parsed.Choices) == 0 { + return "", false + } + return truncateReply(strings.TrimSpace(parsed.Choices[0].Message.Content)), true + } +} + +func truncateReply(reply string) string { + const maxLen = 120 + if len(reply) > maxLen { + return reply[:maxLen] + } + return reply +} diff --git a/server/service/picoclaw/model_meta.go b/server/service/picoclaw/model_meta.go new file mode 100644 index 00000000..04fcc208 --- /dev/null +++ b/server/service/picoclaw/model_meta.go @@ -0,0 +1,213 @@ +package picoclaw + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" +) + +// picoclawModelMetaFileName is a NanoKVM-owned sidecar next to the picoclaw +// config. It records only non-secret hints (which provider/auth method the user +// picked in the UI) so the status endpoint can render accurately. It never +// contains API keys or OAuth tokens. Setups configured before this file existed +// (e.g. the stock Gemini config) simply fall back to prefix inference. +const picoclawModelMetaFileName = ".nanokvm-model.json" + +type picoclawModelMeta struct { + Provider string `json:"provider,omitempty"` + AuthMethod string `json:"auth_method,omitempty"` + ModelName string `json:"model_name,omitempty"` +} + +func resolvePicoclawModelMetaPath() (string, error) { + configPath, err := resolvePicoclawConfigPath() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(configPath), picoclawModelMetaFileName), nil +} + +func loadPicoclawModelMeta() picoclawModelMeta { + path, err := resolvePicoclawModelMetaPath() + if err != nil { + return picoclawModelMeta{} + } + data, err := os.ReadFile(path) + if err != nil { + return picoclawModelMeta{} + } + var meta picoclawModelMeta + if err := json.Unmarshal(data, &meta); err != nil { + return picoclawModelMeta{} + } + return meta +} + +func savePicoclawModelMeta(meta picoclawModelMeta) error { + path, err := resolvePicoclawModelMetaPath() + if err != nil { + return err + } + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} + +// modelIdentifierForName returns the LiteLLM "/" identifier for a +// given model_name, or "" if not present in the model_list. +func modelIdentifierForName(cfg picoclawConfigFile, name string) string { + name = strings.TrimSpace(name) + if name == "" { + return "" + } + for _, model := range cfg.ModelList { + if strings.TrimSpace(model.ModelName) == name { + return strings.TrimSpace(model.Model) + } + } + return "" +} + +// picoclawModelSummary holds everything the UI needs to render the current model +// configuration. No field is a secret: at most it reports whether a key exists. +type picoclawModelSummary struct { + Provider string + ModelName string + ModelIdentifier string + APIBase string + AuthMethod string + ModelConfigured bool + APIKeyConfigured bool + EndpointConfigured bool + OAuthAvailable bool + OAuthAuthenticated bool +} + +// computePicoclawModelSummary derives the model summary from the live picoclaw +// config + security file + NanoKVM sidecar + OAuth capability. Safe to call when +// nothing is configured (returns a mostly-empty summary). +func computePicoclawModelSummary() picoclawModelSummary { + summary := picoclawModelSummary{} + summary.OAuthAvailable = picoclawOAuthCapability().Available + + doc, err := loadPicoclawConfigDocument() + if err != nil { + return summary + } + + cfg := doc.config + targetModelName := resolvePicoclawTargetModelName(cfg) + summary.ModelName = targetModelName + + hasConfigKey := false + for _, model := range cfg.ModelList { + if strings.TrimSpace(model.ModelName) != targetModelName { + continue + } + summary.APIBase = strings.TrimSpace(model.APIBase) + summary.ModelIdentifier = strings.TrimSpace(model.Model) + if configHasModelAPIKeys(model.APIKey, model.APIKeys) { + hasConfigKey = true + } + break + } + summary.EndpointConfigured = summary.APIBase != "" + summary.APIKeyConfigured = hasConfigKey || securityHasModelAPIKeys(doc.security, targetModelName) + + meta := loadPicoclawModelMeta() + if meta.Provider != "" && (meta.ModelName == "" || meta.ModelName == targetModelName) { + summary.Provider = meta.Provider + } + if summary.Provider == "" { + summary.Provider = inferProviderFromModel(summary.ModelIdentifier) + } + + summary.OAuthAuthenticated = summary.OAuthAvailable && isProviderOAuthAuthenticated(summary.Provider) + summary.AuthMethod = resolvePicoclawEffectiveAuthMethod( + meta, targetModelName, summary.APIKeyConfigured, summary.OAuthAuthenticated, + ) + summary.ModelConfigured = isPicoclawModelConfiguredWithAuth( + cfg, doc.security, targetModelName, summary.AuthMethod, summary.OAuthAuthenticated, + ) + + return summary +} + +// resolvePicoclawEffectiveAuthMethod derives the auth method used to decide +// model-configured-ness, identically for the status summary and the gateway +// readiness path so the two never disagree. It trusts the sidecar's auth method +// only when the sidecar applies to the current model, otherwise it infers one +// from which credential actually exists. +func resolvePicoclawEffectiveAuthMethod( + meta picoclawModelMeta, + targetModelName string, + apiKeyConfigured bool, + oauthAuthenticated bool, +) string { + metaApplies := meta.Provider != "" && (meta.ModelName == "" || meta.ModelName == targetModelName) + if metaApplies && meta.AuthMethod != "" { + return meta.AuthMethod + } + switch { + case apiKeyConfigured: + return authMethodAPIKey + case oauthAuthenticated: + return authMethodOAuth + default: + return "" + } +} + +// isPicoclawModelConfiguredWithAuth extends the original api-key-only check to +// also treat no-auth (local) and OAuth-authenticated endpoints as configured, +// without changing behavior for existing api-key setups. +func isPicoclawModelConfiguredWithAuth( + cfg picoclawConfigFile, + security picoclawSecurityConfig, + modelName string, + authMethod string, + oauthAuthenticated bool, +) bool { + if modelName == "" { + return false + } + if isPicoclawModelConfigured(cfg, security, modelName) { + return true + } + + for _, model := range cfg.ModelList { + if strings.TrimSpace(model.ModelName) != modelName { + continue + } + if strings.TrimSpace(model.APIBase) == "" { + return false + } + switch authMethod { + case authMethodNone: + return true + case authMethodOAuth: + return oauthAuthenticated + } + return false + } + return false +} + +// withModelMeta enriches a RuntimeStatus with provider/auth/endpoint facts for +// the HTTP response. It never mutates persisted state and never exposes secrets. +func withModelMeta(status RuntimeStatus) RuntimeStatus { + summary := computePicoclawModelSummary() + status.Provider = summary.Provider + status.AuthMethod = summary.AuthMethod + status.OAuthAvailable = summary.OAuthAvailable + status.OAuthAuthenticated = summary.OAuthAuthenticated + status.APIKeyConfigured = summary.APIKeyConfigured + status.EndpointConfigured = summary.EndpointConfigured + if status.ModelName == "" { + status.ModelName = summary.ModelName + } + return status +} diff --git a/server/service/picoclaw/oauth_login.go b/server/service/picoclaw/oauth_login.go new file mode 100644 index 00000000..b7b8d8df --- /dev/null +++ b/server/service/picoclaw/oauth_login.go @@ -0,0 +1,266 @@ +package picoclaw + +import ( + "bufio" + "context" + "io" + "os/exec" + "regexp" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" +) + +// Device-code OAuth is driven entirely by the picoclaw binary's own native flow: +// +// picoclaw auth login --provider openai --device-code +// +// picoclaw talks to auth.openai.com directly and stores the resulting tokens in +// its own credential store. NanoKVM only: +// - parses the verification URL + user code the command prints, and shows them +// in the web UI (the user authorizes in a browser on their own PC), +// - stops ONLY the picoclaw runtime during login to cut peak memory, restarting +// it afterwards (NanoKVM server, video, HID and KVM keep running), +// - restores the previously-selected default model afterwards, because the +// OpenAI login flips the default to gpt-5.4, +// - records a non-secret "authenticated" hint. +// +// NanoKVM never reads, stores, or forwards the access/refresh tokens, and never +// imports the picoclaw Go module (it only execs the binary). + +const ( + deviceCodeLoginTimeout = 15 * time.Minute + deviceCodeURLWait = 25 * time.Second +) + +const ( + loginStatusIdle = "idle" + loginStatusPending = "pending" + loginStatusAuthenticated = "authenticated" + loginStatusFailed = "failed" +) + +var ( + loginURLRegex = regexp.MustCompile(`https?://[^\s"'<>]+`) + loginCodeRegex = regexp.MustCompile(`\b[A-Z0-9]{4}-[A-Z0-9]{4}\b`) + loginEmailRe = regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`) +) + +type oauthLoginState struct { + Provider string + Status string + LoginURL string + UserCode string + Account string + Error string + StartedAt time.Time +} + +type oauthLoginManager struct { + mu sync.Mutex + state oauthLoginState + running bool + urlDone chan struct{} +} + +var oauthLoginMgr = &oauthLoginManager{} + +func (mgr *oauthLoginManager) snapshot() oauthLoginState { + mgr.mu.Lock() + defer mgr.mu.Unlock() + return mgr.state +} + +func (mgr *oauthLoginManager) observeLine(line string) { + mgr.mu.Lock() + defer mgr.mu.Unlock() + if mgr.state.Status != loginStatusPending { + return + } + + if mgr.state.LoginURL == "" { + if url := loginURLRegex.FindString(line); url != "" { + mgr.state.LoginURL = strings.TrimRight(url, ".,)]}") + } + } + if mgr.state.UserCode == "" { + if code := loginCodeRegex.FindString(line); code != "" { + mgr.state.UserCode = code + } + } + if mgr.state.LoginURL != "" && mgr.urlDone != nil { + select { + case mgr.urlDone <- struct{}{}: + default: + } + } +} + +func (mgr *oauthLoginManager) finish(status, account, errMsg string) { + mgr.mu.Lock() + defer mgr.mu.Unlock() + mgr.state.Status = status + if account != "" { + mgr.state.Account = account + } + mgr.state.Error = errMsg + mgr.running = false +} + +// startDeviceCodeLogin launches the picoclaw device-code login, returning the +// initial state (with the verification URL/code once parsed). The flow continues +// in the background until the user authorizes or it times out. +func (s *Service) startDeviceCodeLogin(provider string) oauthLoginState { + mgr := oauthLoginMgr + + mgr.mu.Lock() + if mgr.running { + current := mgr.state + mgr.mu.Unlock() + return current + } + mgr.running = true + mgr.state = oauthLoginState{Provider: provider, Status: loginStatusPending, StartedAt: time.Now()} + mgr.urlDone = make(chan struct{}, 1) + urlDone := mgr.urlDone + mgr.mu.Unlock() + + // Remember what to put back after login completes. + savedDefault := currentDefaultModelName() + wasRunning := s.runtimeIsActive() + if wasRunning { + _, _, _ = s.stopRuntime() + } + + ctx, cancel := context.WithTimeout(context.Background(), deviceCodeLoginTimeout) + cmd := exec.CommandContext(ctx, picoclawBinaryPath, "auth", "login", "--provider", provider, "--device-code") + + stdout, outErr := cmd.StdoutPipe() + stderr, errErr := cmd.StderrPipe() + if outErr != nil || errErr != nil { + cancel() + mgr.finish(loginStatusFailed, "", "failed to capture picoclaw auth output") + s.restoreAfterLogin(wasRunning, savedDefault) + return mgr.snapshot() + } + + if err := cmd.Start(); err != nil { + cancel() + mgr.finish(loginStatusFailed, "", "failed to start picoclaw auth login") + s.restoreAfterLogin(wasRunning, savedDefault) + return mgr.snapshot() + } + + var collected strings.Builder + var collectMu sync.Mutex + scan := func(reader io.Reader) { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 0, 64*1024), 256*1024) + for scanner.Scan() { + line := scanner.Text() + collectMu.Lock() + collected.WriteString(line) + collected.WriteByte('\n') + collectMu.Unlock() + mgr.observeLine(line) + } + } + var scanWg sync.WaitGroup + scanWg.Add(2) + go func() { defer scanWg.Done(); scan(stdout) }() + go func() { defer scanWg.Done(); scan(stderr) }() + + go func() { + waitErr := cmd.Wait() + scanWg.Wait() + cancel() + + collectMu.Lock() + output := collected.String() + collectMu.Unlock() + + if waitErr != nil { + // Do not log the output verbatim; it may contain a token. Log only + // the coarse failure. + log.Warnf("[picoclaw oauth] device-code login failed for provider=%s", provider) + mgr.finish(loginStatusFailed, "", "OAuth login did not complete") + } else { + account := loginEmailRe.FindString(output) + mgr.finish(loginStatusAuthenticated, account, "") + if err := setProviderOAuthState(provider, picoclawOAuthProviderState{ + Authenticated: true, + Account: account, + }); err != nil { + log.Warnf("[picoclaw oauth] failed to persist auth state: %v", err) + } + } + s.restoreAfterLogin(wasRunning, savedDefault) + }() + + select { + case <-urlDone: + case <-time.After(deviceCodeURLWait): + } + return mgr.snapshot() +} + +// restoreAfterLogin puts the previously-selected default model back (the OpenAI +// login flips it to gpt-5.4) and restarts the picoclaw runtime if it had been +// running before login. +func (s *Service) restoreAfterLogin(wasRunning bool, savedDefault string) { + if savedDefault != "" && currentDefaultModelName() != savedDefault { + if err := restoreDefaultModelName(savedDefault); err != nil { + log.Warnf("[picoclaw oauth] failed to restore default model: %v", err) + } + } + if wasRunning { + if _, _, err := s.startRuntime(); err != nil { + log.Warnf("[picoclaw oauth] failed to restart runtime after login: %s", err.Message) + } + } +} + +func (s *Service) runtimeIsActive() bool { + status := s.runtime.Get() + if status.Ready || status.Status == "ready" { + return true + } + if running, err := isRuntimeRunning(); err == nil && running { + return true + } + return false +} + +func currentDefaultModelName() string { + doc, err := loadPicoclawConfigDocument() + if err != nil { + return "" + } + return resolvePicoclawTargetModelName(doc.config) +} + +func restoreDefaultModelName(modelName string) error { + doc, err := loadPicoclawConfigDocument() + if err != nil { + return err + } + editor := &picoclawConfigEditor{raw: doc.raw} + editor.setValue(modelName, "agents", "defaults", "model_name") + if !editor.changed { + return nil + } + return doc.saveConfig() +} + +func clearDeviceCodeLogin() { + mgr := oauthLoginMgr + mgr.mu.Lock() + defer mgr.mu.Unlock() + if mgr.running { + // A login is in flight; leave it alone. + return + } + mgr.state = oauthLoginState{Status: loginStatusIdle} +} diff --git a/server/service/picoclaw/providers.go b/server/service/picoclaw/providers.go new file mode 100644 index 00000000..ce20642c --- /dev/null +++ b/server/service/picoclaw/providers.go @@ -0,0 +1,194 @@ +package picoclaw + +import "strings" + +// Authentication methods supported by the Configure Model UI. +const ( + authMethodAPIKey = "api_key" + authMethodOAuth = "oauth" + authMethodNone = "none" +) + +// Provider identifiers exposed to the frontend. These are NanoKVM-side labels; +// the actual picoclaw config stores a LiteLLM-style "/" identifier +// plus an api_base, so the provider is otherwise only implicit in the prefix. +const ( + providerOpenAI = "openai" + providerAnthropic = "anthropic" + providerGoogle = "google" + providerOpenAICompatible = "openai_compatible" + providerLocal = "local" + providerCustom = "custom" +) + +// apiTestStyle selects how the "Test Model" route shapes its minimal request. +const ( + testStyleOpenAI = "openai" + testStyleAnthropic = "anthropic" + testStyleGemini = "gemini" +) + +// providerPreset is the maintainable, editable catalog entry for a provider. +// +// Model lists here are recommended *defaults*, not guarantees: the picoclaw +// binary and the upstream provider decide which model names are actually valid. +// The UI uses them to prefill a combo box that always allows a custom name. +type providerPreset struct { + ID string `json:"id"` + Name string `json:"name"` + ModelPrefix string `json:"model_prefix"` + DefaultAPIBase string `json:"default_api_base,omitempty"` + EndpointRequired bool `json:"endpoint_required"` + EndpointEditable bool `json:"endpoint_editable"` + AuthMethods []string `json:"auth_methods"` + Models []string `json:"models"` + // Capabilities surfaced so the UI can warn before sending unsupported + // builtin tool types (e.g. OpenAI Responses "web_search_preview"). + SupportsWebSearchPreview bool `json:"supports_web_search_preview"` + // testStyle is server-internal only (never serialized). + testStyle string +} + +// picoclawProviderCatalog is the single source of truth for the Configure Model +// UI. Edit here to add providers or adjust default presets. +var picoclawProviderCatalog = []providerPreset{ + { + ID: providerOpenAI, + Name: "OpenAI / Codex", + ModelPrefix: "openai", + DefaultAPIBase: "https://api.openai.com/v1", + EndpointRequired: false, + EndpointEditable: true, + AuthMethods: []string{authMethodAPIKey, authMethodOAuth}, + Models: []string{"gpt-5-codex", "gpt-5.5", "gpt-5.3"}, + SupportsWebSearchPreview: true, + testStyle: testStyleOpenAI, + }, + { + ID: providerAnthropic, + Name: "Anthropic / Claude", + ModelPrefix: "anthropic", + DefaultAPIBase: "https://api.anthropic.com", + EndpointRequired: false, + EndpointEditable: true, + AuthMethods: []string{authMethodAPIKey}, + Models: []string{"claude-sonnet-4-6", "claude-opus-4-7"}, + testStyle: testStyleAnthropic, + }, + { + ID: providerGoogle, + Name: "Google Gemini", + ModelPrefix: "gemini", + DefaultAPIBase: "https://generativelanguage.googleapis.com/v1beta", + EndpointRequired: false, + EndpointEditable: true, + AuthMethods: []string{authMethodAPIKey}, + Models: []string{"gemini-3.1-flash-lite", "gemini-3.1-pro"}, + testStyle: testStyleGemini, + }, + { + ID: providerOpenAICompatible, + Name: "OpenAI-compatible endpoint", + ModelPrefix: "openai", + EndpointRequired: true, + EndpointEditable: true, + AuthMethods: []string{authMethodAPIKey, authMethodNone}, + Models: []string{}, + testStyle: testStyleOpenAI, + }, + { + ID: providerLocal, + Name: "Local (LM Studio / Ollama)", + ModelPrefix: "openai", + DefaultAPIBase: "http://localhost:1234/v1", + EndpointRequired: true, + EndpointEditable: true, + AuthMethods: []string{authMethodNone, authMethodAPIKey}, + Models: []string{}, + testStyle: testStyleOpenAI, + }, + { + ID: providerCustom, + Name: "Custom / advanced", + ModelPrefix: "", + EndpointRequired: true, + EndpointEditable: true, + AuthMethods: []string{authMethodAPIKey, authMethodNone}, + Models: []string{}, + testStyle: testStyleOpenAI, + }, +} + +func lookupProviderPreset(id string) (providerPreset, bool) { + id = strings.TrimSpace(strings.ToLower(id)) + for _, preset := range picoclawProviderCatalog { + if preset.ID == id { + return preset, true + } + } + return providerPreset{}, false +} + +func providerAllowsAuthMethod(preset providerPreset, method string) bool { + for _, allowed := range preset.AuthMethods { + if allowed == method { + return true + } + } + return false +} + +// inferProviderFromModel best-effort maps a stored LiteLLM "/" +// identifier back to a catalog provider. Used to keep already-configured setups +// (e.g. an existing "gemini/..." config with no NanoKVM sidecar) displaying +// correctly. When ambiguous (bare "openai/" can be OpenAI or a compatible +// endpoint) it returns the canonical cloud provider; the sidecar overrides this +// whenever the user saved through the new UI. +func inferProviderFromModel(model string) string { + model = strings.TrimSpace(strings.ToLower(model)) + if model == "" { + return "" + } + + prefix := model + if index := strings.Index(model, "/"); index >= 0 { + prefix = model[:index] + } + + switch prefix { + case "gemini", "google", "vertex_ai", "vertex": + return providerGoogle + case "anthropic", "claude": + return providerAnthropic + case "openai", "azure", "azure_ai": + return providerOpenAI + case "ollama", "ollama_chat", "lm_studio", "lmstudio": + return providerLocal + default: + return providerCustom + } +} + +// buildModelIdentifier composes the LiteLLM "/" identifier from a +// provider and a (possibly already-prefixed) bare model name. If the caller +// already supplied a prefix, it is respected. +func buildModelIdentifier(preset providerPreset, model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "" + } + if strings.Contains(model, "/") { + return model + } + if preset.ModelPrefix == "" { + return model + } + return preset.ModelPrefix + "/" + model +} + +// Provider tool capabilities are surfaced to the UI via the SupportsWebSearchPreview +// field on each preset (serialized in GET /model/config). NanoKVM only ever injects +// standard MCP function tools (kvm_screenshot, kvm_actions), which every provider +// accepts; provider-specific builtin tools such as the OpenAI Responses +// "web_search_preview" are added by the picoclaw binary itself, so the capability +// flag is advisory context for the UI rather than a server-enforced filter. diff --git a/server/service/picoclaw/runtime_handlers.go b/server/service/picoclaw/runtime_handlers.go index 38ce1845..9d8d08a1 100644 --- a/server/service/picoclaw/runtime_handlers.go +++ b/server/service/picoclaw/runtime_handlers.go @@ -19,7 +19,7 @@ func (s *Service) StartRuntime(c *gin.Context) { Started: true, Command: command, Output: output, - Status: s.runtime.Get(), + Status: withModelMeta(withAgentProfile(s.runtime.Get())), }) } @@ -34,7 +34,7 @@ func (s *Service) StopRuntime(c *gin.Context) { Started: false, Command: command, Output: output, - Status: s.runtime.Get(), + Status: withModelMeta(withAgentProfile(s.runtime.Get())), }) } @@ -51,7 +51,7 @@ func (s *Service) InstallRuntime(c *gin.Context) { Binary: picoclawBinaryPath, Download: picoclawDownloadURL, Output: output, - Status: currentStatus, + Status: withModelMeta(withAgentProfile(currentStatus)), }) } @@ -105,6 +105,6 @@ func (s *Service) UninstallRuntime(c *gin.Context) { Binary: picoclawBinaryPath, Download: picoclawDownloadURL, Output: uninstallOutput, - Status: s.runtime.Get(), + Status: withModelMeta(withAgentProfile(s.runtime.Get())), }) } diff --git a/server/service/picoclaw/service.go b/server/service/picoclaw/service.go index 03055faf..d8271033 100644 --- a/server/service/picoclaw/service.go +++ b/server/service/picoclaw/service.go @@ -78,7 +78,7 @@ func (s *Service) GetRuntimeStatus(c *gin.Context) { }) } - writeSuccess(c, withAgentProfile(status)) + writeSuccess(c, withModelMeta(withAgentProfile(status))) } func (s *Service) GetRuntimeSession(c *gin.Context) { diff --git a/server/service/picoclaw/types.go b/server/service/picoclaw/types.go index c928714c..900f1154 100644 --- a/server/service/picoclaw/types.go +++ b/server/service/picoclaw/types.go @@ -50,20 +50,28 @@ type Config struct { } type RuntimeStatus struct { - Ready bool `json:"ready"` - Installed bool `json:"installed"` - Installing bool `json:"installing"` - InstallProgress int `json:"install_progress,omitempty"` - InstallStage string `json:"install_stage,omitempty"` - InstallPath string `json:"install_path,omitempty"` - AgentProfile string `json:"agent_profile,omitempty"` - ModelConfigured bool `json:"model_configured"` - ModelName string `json:"model_name,omitempty"` - Status string `json:"status"` - ConfigError string `json:"config_error,omitempty"` - LastError string `json:"last_error,omitempty"` - CheckedAt time.Time `json:"checked_at,omitempty"` - CurrentSession string `json:"current_session,omitempty"` + Ready bool `json:"ready"` + Installed bool `json:"installed"` + Installing bool `json:"installing"` + InstallProgress int `json:"install_progress,omitempty"` + InstallStage string `json:"install_stage,omitempty"` + InstallPath string `json:"install_path,omitempty"` + AgentProfile string `json:"agent_profile,omitempty"` + ModelConfigured bool `json:"model_configured"` + ModelName string `json:"model_name,omitempty"` + // Provider/auth metadata derived for the Configure Model UI. None of these + // expose secrets — at most they report whether a credential exists. + Provider string `json:"provider,omitempty"` + AuthMethod string `json:"auth_method,omitempty"` + OAuthAvailable bool `json:"oauth_available"` + OAuthAuthenticated bool `json:"oauth_authenticated"` + APIKeyConfigured bool `json:"api_key_configured"` + EndpointConfigured bool `json:"endpoint_configured"` + Status string `json:"status"` + ConfigError string `json:"config_error,omitempty"` + LastError string `json:"last_error,omitempty"` + CheckedAt time.Time `json:"checked_at,omitempty"` + CurrentSession string `json:"current_session,omitempty"` } type RuntimeStartResult struct { diff --git a/web/src/api/picoclaw.ts b/web/src/api/picoclaw.ts index b833a188..79bc0825 100644 --- a/web/src/api/picoclaw.ts +++ b/web/src/api/picoclaw.ts @@ -7,10 +7,26 @@ import { const sessionIDHeader = 'X-PicoClaw-Session-ID'; -type ModelConfigRequest = { +export type ModelConfigRequest = { + provider?: string; model: string; - api_base: string; - api_key: string; + api_base?: string; + api_key?: string; + auth_method?: string; +}; + +export type ModelTestRequest = { + provider?: string; + model?: string; + api_base?: string; + api_key?: string; + auth_method?: string; +}; + +export type AuthCallbackRequest = { + provider: string; + code?: string; + state?: string; }; type AgentProfileRequest = { @@ -43,6 +59,30 @@ export function setPicoclawModelConfig(data: ModelConfigRequest) { return http.post('/api/picoclaw/model/config', data); } +export function getPicoclawModelConfig() { + return http.get('/api/picoclaw/model/config'); +} + +export function testPicoclawModel(data?: ModelTestRequest) { + return http.post('/api/picoclaw/model/test', data ?? {}); +} + +export function getPicoclawAuthStatus(provider: string) { + return http.get('/api/picoclaw/auth/status', { provider }); +} + +export function startPicoclawAuthLogin(provider: string) { + return http.post('/api/picoclaw/auth/login', { provider }); +} + +export function picoclawAuthCallback(data: AuthCallbackRequest) { + return http.post('/api/picoclaw/auth/callback', data); +} + +export function picoclawAuthLogout(provider: string) { + return http.post('/api/picoclaw/auth/logout', { provider }); +} + export function setPicoclawAgentProfile(data: AgentProfileRequest) { return http.post('/api/picoclaw/agent/profile', data); } diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index 7148c5a9..2bfaa312 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -563,20 +563,98 @@ const en = { model: { requiredTitle: 'Model configuration is required', requiredDescription: 'Configure the PicoClaw model before using PicoClaw chat.', + configureTitle: 'Configure Model', + configureDescription: + 'Choose a provider, model, and authentication for the PicoClaw runtime.', docsTitle: 'Configuration Guide', docsDesc: 'Supported models and protocols', menuLabel: 'Configure model', - modelIdentifier: 'Model Identifier', - modelIdentifierPlaceholder: 'openai/gpt-5.4', + provider: 'Provider', + authMethod: 'Authentication', + modelIdentifier: 'Model', + modelIdentifierPlaceholder: 'e.g. gemini-3.1-flash-lite', + custom: 'Custom model…', + advanced: 'Advanced: custom base URL', apiBase: 'API Base URL', apiBasePlaceholder: 'https://api.example.com/v1', apiKey: 'API Key', apiKeyPlaceholder: 'Enter the model API key', + apiKeyKeep: '•••••••• (configured — leave blank to keep)', save: 'Save', saving: 'Saving', saved: 'Model configuration saved', saveFailed: 'Failed to save model configuration', - invalid: 'Model identifier, API base URL, and API key are required' + invalid: 'Model identifier, API base URL, and API key are required', + auth: { + apiKey: 'API key', + oauth: 'OAuth / ChatGPT subscription', + none: 'No auth (local endpoint)', + apiKeyHint: 'Uses pay-as-you-go API billing for the provider.', + oauthHint: 'Uses your ChatGPT/Codex subscription plan limits — not API billing.', + noneHint: 'No credentials are sent. For local endpoints only.' + }, + oauth: { + unavailableTitle: 'OAuth is unavailable', + missing: 'Missing backend command:', + signedInAs: 'Signed in', + signOut: 'Sign out', + headlessHint: + 'Start login, then open the URL on another device to authorize. NanoKVM has no browser of its own.', + memoryHint: + 'NanoKVM briefly stops only the PicoClaw runtime during login to save memory, then restarts it.', + start: 'Start OAuth Login', + openUrl: 'Open this URL to sign in:', + code: 'Code:', + pasteCode: 'Paste verification code', + submit: 'Submit', + waiting: 'Waiting for authorization…', + checkNow: 'Check now', + failed: 'OAuth login failed. Please try again.' + }, + test: { + button: 'Test Model', + success: 'Success — model replied: {{reply}}', + auth: 'Authentication failed. Check your API key or OAuth login.', + model: 'Model not found or invalid for this provider.', + endpoint: 'The base URL is invalid or unreachable.', + network: 'Network error reaching the provider.', + parser: 'Got a response but could not parse the model output (possible streaming/parser issue).', + unsupported: 'The provider rejected an unsupported request or tool type.', + oauthNotTestable: + 'OAuth setups are handled by the PicoClaw runtime and cannot be tested directly from NanoKVM. Start the runtime and send a chat message instead.', + notConfigured: 'No model configured to test.', + unknown: 'Unknown error.' + }, + capability: { + webSearchSupported: 'Built-in web search (web_search_preview) is supported by this provider.', + webSearchUnsupported: 'Built-in web search (web_search_preview) is not available for this provider.' + }, + err: { + modelRequired: 'Enter a model name.', + endpointRequired: 'A base URL is required for this provider.', + keyRequired: 'An API key is required for API key auth.', + oauthRequired: 'Sign in with OAuth before saving.' + } + }, + badge: { + installed: 'Installed', + runtime: 'Runtime', + model: 'Model', + provider: 'Provider', + auth: 'Auth', + apiKey: 'API key', + endpoint: 'Endpoint', + oauth: 'OAuth', + checked: 'Checked', + yes: 'Yes', + no: 'No', + ready: 'Ready', + notReady: 'Not ready', + set: 'Set', + missing: 'Missing', + unavailable: 'Unavailable', + signedIn: 'Signed in', + signedOut: 'Signed out' }, uninstall: { menuLabel: 'Uninstall', diff --git a/web/src/jotai/picoclaw.ts b/web/src/jotai/picoclaw.ts index 2fea45ff..cb93d0c7 100644 --- a/web/src/jotai/picoclaw.ts +++ b/web/src/jotai/picoclaw.ts @@ -16,6 +16,12 @@ export type PicoclawRuntimeStatus = { agent_profile?: string; model_configured: boolean; model_name?: string; + provider?: string; + auth_method?: string; + oauth_available?: boolean; + oauth_authenticated?: boolean; + api_key_configured?: boolean; + endpoint_configured?: boolean; status: string; config_error?: string; last_error?: string; @@ -23,6 +29,69 @@ export type PicoclawRuntimeStatus = { current_session?: string; }; +export type PicoclawProviderPreset = { + id: string; + name: string; + model_prefix: string; + default_api_base?: string; + endpoint_required: boolean; + endpoint_editable: boolean; + auth_methods: string[]; + models: string[]; + supports_web_search_preview?: boolean; +}; + +export type PicoclawModelConfig = { + provider: string; + model_name: string; + model_identifier: string; + api_base: string; + auth_method: string; + model_configured: boolean; + api_key_configured: boolean; + endpoint_configured: boolean; + oauth_available: boolean; + oauth_authenticated: boolean; + agent_profile: string; + providers: PicoclawProviderPreset[]; +}; + +export type PicoclawAuthStatus = { + provider: string; + available: boolean; + authenticated: boolean; + status?: string; + login_url?: string; + user_code?: string; + account?: string; + expires_at?: string; + error?: string; + unavailable_reason?: string; + missing_command?: string; +}; + +export type PicoclawAuthLogin = { + provider: string; + available: boolean; + status: string; + login_url?: string; + user_code?: string; + verification_uri?: string; + state?: string; + expires_in?: number; + requires_code?: boolean; + unavailable_reason?: string; + missing_command?: string; +}; + +export type PicoclawModelTestResult = { + ok: boolean; + outcome: string; + reply?: string; + status_code?: number; + log_ref?: string; +}; + export type PicoclawRuntimeStartResult = { started: boolean; command: string; diff --git a/web/src/pages/desktop/picoclaw/sidebar-actions.ts b/web/src/pages/desktop/picoclaw/sidebar-actions.ts index f6d8f024..dd297f56 100644 --- a/web/src/pages/desktop/picoclaw/sidebar-actions.ts +++ b/web/src/pages/desktop/picoclaw/sidebar-actions.ts @@ -8,7 +8,6 @@ import { installRuntime, picoclawGateway, setPicoclawAgentProfile, - setPicoclawModelConfig, startRuntime, stopRuntime, uninstallRuntime @@ -38,9 +37,6 @@ type PicoclawSidebarActionOptions = { runtimeStatus: PicoclawRuntimeStatus | null; transportState: PicoclawTransportState; installSnapshot: PicoclawRuntimeInstallSnapshot | null; - modelApiBase: string; - modelApiKey: string; - modelIdentifier: string; setRuntimeStatus: RuntimeStatusSetter; setMessages: MessageSetter; setTakeover: TakeoverSetter; @@ -51,7 +47,6 @@ type PicoclawSidebarActionOptions = { setIsTogglingRuntime: Dispatch>; setIsInstallRequestPending: Dispatch>; setInstallSnapshot: Dispatch>; - setIsSavingModelConfig: Dispatch>; setIsSwitchingAgent: Dispatch>; setIsUninstallRequestPending: Dispatch>; }; @@ -62,9 +57,6 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio runtimeStatus, transportState, installSnapshot, - modelApiBase, - modelApiKey, - modelIdentifier, setRuntimeStatus, setMessages, setTakeover, @@ -75,7 +67,6 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio setIsTogglingRuntime, setIsInstallRequestPending, setInstallSnapshot, - setIsSavingModelConfig, setIsSwitchingAgent, setIsUninstallRequestPending } = options; @@ -293,60 +284,23 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio } } - async function handleSaveModelConfig() { - const apiBase = modelApiBase.trim(); - const apiKey = modelApiKey.trim(); - const model = modelIdentifier.trim(); - if (!apiBase || !apiKey || !model) { - setMessages((current) => [ - ...current, - createErrorMessage({ - code: 'MODEL_CONFIG_INVALID', - message: t('picoclaw.model.invalid') - }) - ]); - return; + // Called by the Configure Model modal after it persists a config itself. The + // modal owns validation, the API call, and error display; here we only update + // shared sidebar state and (re)connect when the runtime is ready. + async function handleModelConfigSaved(status: PicoclawRuntimeStatus | null) { + if (status) { + setRuntimeStatus(status); } + setIsModelConfigOpen(false); + setMessages((current) => [...current, createStatusMessage(t('picoclaw.model.saved'))]); - setIsSavingModelConfig(true); - try { - const response = await setPicoclawModelConfig({ - model, - api_base: apiBase, - api_key: apiKey - }); - if (response.code === 0) { - setRuntimeStatus(response.data.status); - setIsModelConfigOpen(false); - setMessages((current) => [...current, createStatusMessage(t('picoclaw.model.saved'))]); - await refreshState(); - return; + const latest = await refreshState(); + if (latest?.ready === true && transportState !== 'connected') { + try { + await connectGateway(); + } catch { + // handled by gateway events } - - const errorMessage = - (response as { message?: string; msg?: string }).message || - (response as { message?: string; msg?: string }).msg || - t('picoclaw.model.saveFailed'); - setMessages((current) => [ - ...current, - createErrorMessage({ - code: 'MODEL_CONFIG_SAVE_FAILED', - message: errorMessage, - raw: response - }) - ]); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : t('picoclaw.model.saveFailed'); - setMessages((current) => [ - ...current, - createErrorMessage({ - code: 'MODEL_CONFIG_SAVE_FAILED', - message: errorMessage, - raw: error - }) - ]); - } finally { - setIsSavingModelConfig(false); } } @@ -449,7 +403,7 @@ export function createPicoclawSidebarActions(options: PicoclawSidebarActionOptio refreshState, handleStartRuntime, handleInstallRuntime, - handleSaveModelConfig, + handleModelConfigSaved, handleAgentProfileChange, handleUninstallRuntime }; diff --git a/web/src/pages/desktop/picoclaw/sidebar-model-config.tsx b/web/src/pages/desktop/picoclaw/sidebar-model-config.tsx index ec54138c..a504cb41 100644 --- a/web/src/pages/desktop/picoclaw/sidebar-model-config.tsx +++ b/web/src/pages/desktop/picoclaw/sidebar-model-config.tsx @@ -1,41 +1,72 @@ import { useEffect } from 'react'; -import { Button, Input } from 'antd'; +import { Alert, Button, Input, Select, Spin, Switch } from 'antd'; import { useSetAtom } from 'jotai'; -import { BookOpenIcon, CpuIcon, ExternalLinkIcon, KeyRoundIcon, LinkIcon, SaveIcon } from 'lucide-react'; +import { + AlertTriangleIcon, + BadgeCheckIcon, + BookOpenIcon, + CheckCircle2Icon, + CpuIcon, + ExternalLinkIcon, + KeyRoundIcon, + LinkIcon, + Loader2Icon, + LogInIcon, + PlugIcon, + SaveIcon, + ShieldIcon, + XCircleIcon +} from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts'; +import type { PicoclawRuntimeStatus } from '@/jotai/picoclaw.ts'; + +import { AUTH_API_KEY, AUTH_NONE, AUTH_OAUTH, useModelConfig } from './use-model-config.ts'; + +const CUSTOM_MODEL = '__custom__'; type SidebarModelConfigProps = { - apiBase: string; - apiKey: string; - isSaving: boolean; - modelIdentifier: string; - modelName?: string; - onApiBaseChange: (value: string) => void; - onApiKeyChange: (value: string) => void; - onModelIdentifierChange: (value: string) => void; - onSave: () => void | Promise; + runtimeStatus: PicoclawRuntimeStatus | null; + onSaved: (status: PicoclawRuntimeStatus | null) => void; onCancel?: () => void; showCancel?: boolean; }; +function Badge({ + label, + value, + tone +}: { + label: string; + value: string; + tone: 'ok' | 'warn' | 'bad' | 'muted'; +}) { + const toneClass = { + ok: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300', + warn: 'border-amber-500/30 bg-amber-500/10 text-amber-300', + bad: 'border-red-500/30 bg-red-500/10 text-red-300', + muted: 'border-white/[0.08] bg-white/[0.04] text-neutral-400' + }[tone]; + return ( +
+ {label} + {value} +
+ ); +} + export const SidebarModelConfig = ({ - apiBase, - apiKey, - isSaving, - modelIdentifier, - modelName, - onApiBaseChange, - onApiKeyChange, - onModelIdentifierChange, - onSave, + runtimeStatus, + onSaved, onCancel, showCancel = false }: SidebarModelConfigProps) => { const { t } = useTranslation(); const setIsKeyboardEnable = useSetAtom(isKeyboardEnableAtom); + const m = useModelConfig({ onSaved }); + useEffect(() => { setIsKeyboardEnable(false); return () => { @@ -43,15 +74,138 @@ export const SidebarModelConfig = ({ }; }, [setIsKeyboardEnable]); + const authLabel = (method: string) => { + switch (method) { + case AUTH_OAUTH: + return t('picoclaw.model.auth.oauth', 'OAuth / ChatGPT subscription'); + case AUTH_NONE: + return t('picoclaw.model.auth.none', 'No auth (local endpoint)'); + default: + return t('picoclaw.model.auth.apiKey', 'API key'); + } + }; + + const errorText = (code: string) => { + const map: Record = { + model_required: t('picoclaw.model.err.modelRequired', 'Enter a model name.'), + endpoint_required: t('picoclaw.model.err.endpointRequired', 'A base URL is required for this provider.'), + key_required: t('picoclaw.model.err.keyRequired', 'An API key is required for API key auth.'), + oauth_required: t('picoclaw.model.err.oauthRequired', 'Sign in with OAuth before saving.'), + save_failed: t('picoclaw.model.saveFailed', 'Failed to save model configuration') + }; + return map[code] || code; + }; + + const testText = () => { + if (!m.testResult) return null; + const r = m.testResult; + const ref = r.log_ref ? ` (ref ${r.log_ref})` : ''; + switch (r.outcome) { + case 'success': + return { + type: 'success' as const, + msg: t('picoclaw.model.test.success', 'Success — model replied: {{reply}}', { + reply: r.reply || 'OK' + }) + }; + case 'auth_error': + return { type: 'error' as const, msg: t('picoclaw.model.test.auth', 'Authentication failed. Check your API key or OAuth login.') }; + case 'invalid_model': + return { type: 'error' as const, msg: t('picoclaw.model.test.model', 'Model not found or invalid for this provider.') }; + case 'invalid_endpoint': + return { type: 'error' as const, msg: t('picoclaw.model.test.endpoint', 'The base URL is invalid or unreachable.') }; + case 'network_error': + return { type: 'error' as const, msg: t('picoclaw.model.test.network', 'Network error reaching the provider.') }; + case 'parser_error': + return { type: 'warning' as const, msg: t('picoclaw.model.test.parser', 'Got a response but could not parse the model output (possible streaming/parser issue).') }; + case 'unsupported': + return { type: 'warning' as const, msg: t('picoclaw.model.test.unsupported', 'The provider rejected an unsupported request or tool type.') }; + case 'oauth_not_testable': + return { type: 'warning' as const, msg: t('picoclaw.model.test.oauthNotTestable', 'OAuth setups are handled by the PicoClaw runtime and cannot be tested directly from NanoKVM. Start the runtime and send a chat message instead.') }; + case 'not_configured': + return { type: 'warning' as const, msg: t('picoclaw.model.test.notConfigured', 'No model configured to test.') }; + default: + return { type: 'error' as const, msg: t('picoclaw.model.test.unknown', 'Unknown error.') + ref }; + } + }; + + if (m.loading) { + return ( +
+ +
+ ); + } + + const status = runtimeStatus; + const providerName = m.currentPreset?.name || status?.provider || '—'; + const presetSelectValue = m.presetModels.includes(m.model) ? m.model : CUSTOM_MODEL; + const testResultView = testText(); + const oauthUnavailable = m.authMethod === AUTH_OAUTH && m.authStatus && m.authStatus.available === false; + const oauthLoginUrl = m.loginInfo?.login_url || m.authStatus?.login_url; + const oauthUserCode = m.loginInfo?.user_code || m.authStatus?.user_code; + const oauthPending = m.authStatus?.status === 'pending'; + const oauthFailed = m.authStatus?.status === 'failed'; + return ( -
+
{/* Title */}
- {t('picoclaw.model.requiredTitle')} + {t('picoclaw.model.configureTitle', 'Configure Model')} +
+
+ {t('picoclaw.model.configureDescription', 'Choose a provider, model, and authentication for the PicoClaw runtime.')}
-
- {t('picoclaw.model.requiredDescription')} - {modelName && {modelName}} + + {/* Status badges */} +
+ + + + + + + + +
{/* Documentation Link */} @@ -79,52 +233,259 @@ export const SidebarModelConfig = ({ {/* Fields */}
+ {/* Provider */}
- } - placeholder={t('picoclaw.model.modelIdentifierPlaceholder')} - value={modelIdentifier} - onChange={(e) => onModelIdentifierChange(e.target.value)} + ({ value: method, label: authLabel(method) }))} + suffixIcon={} /> +
+ {m.authMethod === AUTH_OAUTH + ? t('picoclaw.model.auth.oauthHint', 'Uses your ChatGPT/Codex subscription plan limits — not API billing.') + : m.authMethod === AUTH_NONE + ? t('picoclaw.model.auth.noneHint', 'No credentials are sent. For local endpoints only.') + : t('picoclaw.model.auth.apiKeyHint', 'Uses pay-as-you-go API billing for the provider.')} +
+ + {/* OAuth panel */} + {m.authMethod === AUTH_OAUTH && ( +
+ {oauthUnavailable ? ( + +
{m.authStatus?.unavailable_reason}
+ {m.authStatus?.missing_command && ( +
+ {t('picoclaw.model.oauth.missing', 'Missing backend command:')}{' '} + {m.authStatus.missing_command} +
+ )} +
+ } + /> + ) : m.authStatus?.authenticated ? ( +
+
+ + + {t('picoclaw.model.oauth.signedInAs', 'Signed in')} + {m.authStatus.account ? `: ${m.authStatus.account}` : ''} + +
+ +
+ ) : ( +
+
+ {t('picoclaw.model.oauth.headlessHint', 'Start login, then open the URL on another device to authorize. NanoKVM has no browser of its own.')} +
+
+ {t('picoclaw.model.oauth.memoryHint', 'NanoKVM briefly stops only the PicoClaw runtime during login to save memory, then restarts it.')} +
+ + + {oauthLoginUrl && ( +
+
+ {t('picoclaw.model.oauth.openUrl', 'Open this URL to sign in:')} +
+ + {oauthLoginUrl} + + {oauthUserCode && ( +
+ {t('picoclaw.model.oauth.code', 'Code:')}{' '} + {oauthUserCode} +
+ )} + {oauthPending && ( +
+ + + {t('picoclaw.model.oauth.waiting', 'Waiting for authorization…')} + + +
+ )} +
+ )} + + {oauthFailed && ( + + )} +
+ )} +
+ )} + + {/* Model */}
+ {m.presetModels.length > 0 && ( + } - placeholder={t('picoclaw.model.apiBasePlaceholder')} - value={apiBase} - onChange={(e) => onApiBaseChange(e.target.value)} + prefix={} + placeholder={t('picoclaw.model.modelIdentifierPlaceholder', 'e.g. gemini-3.1-flash-lite')} + value={m.model} + onChange={(e) => m.setModel(e.target.value)} /> + {m.currentPreset && ( +
+ {m.currentPreset.supports_web_search_preview + ? t('picoclaw.model.capability.webSearchSupported', 'Built-in web search (web_search_preview) is supported by this provider.') + : t('picoclaw.model.capability.webSearchUnsupported', 'Built-in web search (web_search_preview) is not available for this provider.')} +
+ )}
+ + {/* API key */} + {m.authMethod === AUTH_API_KEY && ( +
+ + } + placeholder={ + m.apiKeyConfigured + ? t('picoclaw.model.apiKeyKeep', '•••••••• (configured — leave blank to keep)') + : t('picoclaw.model.apiKeyPlaceholder', 'Enter the model API key') + } + value={m.apiKey} + onChange={(e) => m.setApiKey(e.target.value)} + /> +
+ )} + + {/* Advanced toggle for cloud providers */} + {m.currentPreset?.endpoint_editable && !m.endpointRequired && ( +
+ + {t('picoclaw.model.advanced', 'Advanced: custom base URL')} + + +
+ )} + + {/* Endpoint */} + {m.endpointVisible && ( +
+ + } + placeholder={ + m.currentPreset?.default_api_base || + t('picoclaw.model.apiBasePlaceholder', 'https://api.example.com/v1') + } + value={m.apiBase} + onChange={(e) => m.setApiBase(e.target.value)} + /> +
+ )}
- {/* Save */} -
- {showCancel && } - +
+ {showCancel && onCancel && } + +
); diff --git a/web/src/pages/desktop/picoclaw/sidebar.tsx b/web/src/pages/desktop/picoclaw/sidebar.tsx index f6847314..e4faa1c5 100644 --- a/web/src/pages/desktop/picoclaw/sidebar.tsx +++ b/web/src/pages/desktop/picoclaw/sidebar.tsx @@ -24,13 +24,10 @@ export const Sidebar = () => { handleAgentProfileChange, handleCloseHistory, handleDeleteHistorySession, - handleModelApiBaseChange, - handleModelApiKeyChange, - handleModelIdentifierChange, + handleModelConfigSaved, handleNewConversation, handleOpenHistory, handleOpenModelConfig, - handleSaveModelConfig, handleSelectHistorySession, historySessions, activeSessionId, @@ -42,11 +39,7 @@ export const Sidebar = () => { isSwitchingSession, installProgress, installStage, - isSavingModelConfig, isSwitchingAgent, - modelApiBase, - modelApiKey, - modelIdentifier, isModelConfigOpen, runState, transportState, @@ -102,15 +95,8 @@ export const Sidebar = () => { /> ) : sidebarMode === 'model' ? ( diff --git a/web/src/pages/desktop/picoclaw/use-model-config.ts b/web/src/pages/desktop/picoclaw/use-model-config.ts new file mode 100644 index 00000000..7d25f438 --- /dev/null +++ b/web/src/pages/desktop/picoclaw/use-model-config.ts @@ -0,0 +1,334 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { + getPicoclawAuthStatus, + getPicoclawModelConfig, + picoclawAuthCallback, + picoclawAuthLogout, + setPicoclawModelConfig, + startPicoclawAuthLogin, + testPicoclawModel, + type ModelConfigRequest, + type ModelTestRequest +} from '@/api/picoclaw.ts'; +import type { + PicoclawAuthLogin, + PicoclawAuthStatus, + PicoclawModelConfig, + PicoclawModelTestResult, + PicoclawProviderPreset, + PicoclawRuntimeStatus +} from '@/jotai/picoclaw.ts'; + +export const AUTH_API_KEY = 'api_key'; +export const AUTH_OAUTH = 'oauth'; +export const AUTH_NONE = 'none'; + +type UseModelConfigOptions = { + onSaved: (status: PicoclawRuntimeStatus | null) => void; +}; + +function isOk(response: { code: number }) { + return response.code === 0; +} + +function errorMessage(response: unknown, fallback: string) { + const value = response as { message?: string; msg?: string } | undefined; + return value?.message || value?.msg || fallback; +} + +export function useModelConfig({ onSaved }: UseModelConfigOptions) { + const [loading, setLoading] = useState(true); + const [providers, setProviders] = useState([]); + const [config, setConfig] = useState(null); + + const [provider, setProvider] = useState(''); + const [authMethod, setAuthMethod] = useState(AUTH_API_KEY); + const [model, setModel] = useState(''); + const [apiBase, setApiBase] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [advanced, setAdvanced] = useState(false); + + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + + const [authStatus, setAuthStatus] = useState(null); + const [loginInfo, setLoginInfo] = useState(null); + const [deviceCode, setDeviceCode] = useState(''); + const [oauthBusy, setOauthBusy] = useState(false); + + const currentPreset = useMemo( + () => providers.find((item) => item.id === provider), + [providers, provider] + ); + + const refreshAuthStatus = useCallback(async (target: string) => { + try { + const response = await getPicoclawAuthStatus(target); + if (isOk(response)) { + setAuthStatus(response.data as PicoclawAuthStatus); + } + } catch { + // status badge falls back to "unknown"; non-fatal + } + }, []); + + useEffect(() => { + let active = true; + (async () => { + try { + const response = await getPicoclawModelConfig(); + if (!active || !isOk(response)) { + return; + } + const cfg = response.data as PicoclawModelConfig; + setConfig(cfg); + setProviders(cfg.providers || []); + + const initialProvider = cfg.provider || cfg.providers?.[0]?.id || 'openai'; + const preset = cfg.providers?.find((item) => item.id === initialProvider); + const initialAuth = cfg.auth_method || preset?.auth_methods?.[0] || AUTH_API_KEY; + + setProvider(initialProvider); + setAuthMethod(initialAuth); + setModel(cfg.model_name || ''); + setApiBase(cfg.api_base || ''); + + if (initialAuth === AUTH_OAUTH) { + void refreshAuthStatus(initialProvider); + } + } finally { + if (active) { + setLoading(false); + } + } + })(); + return () => { + active = false; + }; + }, [refreshAuthStatus]); + + // Poll while a device-code login is pending so the badge flips to + // authenticated once the user authorizes in their browser. + useEffect(() => { + if (authMethod !== AUTH_OAUTH || authStatus?.status !== 'pending') { + return; + } + const id = setInterval(() => { + void refreshAuthStatus(provider); + }, 4000); + return () => clearInterval(id); + }, [authMethod, authStatus?.status, provider, refreshAuthStatus]); + + const checkAuthStatus = useCallback( + () => refreshAuthStatus(provider), + [provider, refreshAuthStatus] + ); + + const changeProvider = useCallback( + (next: string) => { + const preset = providers.find((item) => item.id === next); + const methods = preset?.auth_methods?.length ? preset.auth_methods : [AUTH_API_KEY]; + setProvider(next); + setAuthMethod(methods[0]); + setModel(''); + setApiKey(''); + setError(''); + setTestResult(null); + setLoginInfo(null); + setApiBase(preset?.endpoint_required ? preset.default_api_base || '' : ''); + if (methods[0] === AUTH_OAUTH) { + void refreshAuthStatus(next); + } + }, + [providers, refreshAuthStatus] + ); + + const changeAuthMethod = useCallback( + (next: string) => { + setAuthMethod(next); + setError(''); + setTestResult(null); + if (next === AUTH_OAUTH) { + void refreshAuthStatus(provider); + } + }, + [provider, refreshAuthStatus] + ); + + const startOAuth = useCallback(async () => { + setOauthBusy(true); + setError(''); + try { + const response = await startPicoclawAuthLogin(provider); + if (isOk(response)) { + setLoginInfo(response.data as PicoclawAuthLogin); + } else { + setError(errorMessage(response, 'OAuth login could not be started')); + } + await refreshAuthStatus(provider); + } catch (caught) { + setError(caught instanceof Error ? caught.message : 'OAuth login could not be started'); + } finally { + setOauthBusy(false); + } + }, [provider, refreshAuthStatus]); + + const submitOAuthCode = useCallback(async () => { + setOauthBusy(true); + setError(''); + try { + const response = await picoclawAuthCallback({ + provider, + code: deviceCode.trim(), + state: loginInfo?.state + }); + if (!isOk(response)) { + setError(errorMessage(response, 'OAuth verification failed')); + } else { + setLoginInfo(null); + setDeviceCode(''); + } + await refreshAuthStatus(provider); + } catch (caught) { + setError(caught instanceof Error ? caught.message : 'OAuth verification failed'); + } finally { + setOauthBusy(false); + } + }, [provider, deviceCode, loginInfo, refreshAuthStatus]); + + const logoutOAuth = useCallback(async () => { + setOauthBusy(true); + try { + await picoclawAuthLogout(provider); + setLoginInfo(null); + await refreshAuthStatus(provider); + } catch { + // non-fatal + } finally { + setOauthBusy(false); + } + }, [provider, refreshAuthStatus]); + + const buildPayload = useCallback((): ModelConfigRequest & ModelTestRequest => { + const payload: ModelConfigRequest & ModelTestRequest = { + provider, + model: model.trim(), + auth_method: authMethod + }; + if (apiBase.trim()) { + payload.api_base = apiBase.trim(); + } + if (authMethod === AUTH_API_KEY && apiKey.trim()) { + payload.api_key = apiKey.trim(); + } + return payload; + }, [provider, model, authMethod, apiBase, apiKey]); + + const validate = useCallback((): string => { + if (!model.trim()) { + return 'model_required'; + } + const needsEndpoint = currentPreset?.endpoint_required; + if (needsEndpoint && !apiBase.trim() && !currentPreset?.default_api_base) { + return 'endpoint_required'; + } + if (authMethod === AUTH_API_KEY) { + const keptKey = + Boolean(config?.api_key_configured) && model.trim() === (config?.model_name || ''); + if (!apiKey.trim() && !keptKey) { + return 'key_required'; + } + } + if (authMethod === AUTH_OAUTH && !authStatus?.authenticated) { + return 'oauth_required'; + } + return ''; + }, [model, currentPreset, apiBase, authMethod, apiKey, config, authStatus]); + + const save = useCallback(async () => { + const validation = validate(); + if (validation) { + setError(validation); + return; + } + setSaving(true); + setError(''); + try { + const response = await setPicoclawModelConfig(buildPayload()); + if (isOk(response)) { + onSaved((response.data?.status as PicoclawRuntimeStatus) ?? null); + return; + } + setError(errorMessage(response, 'save_failed')); + } catch (caught) { + setError(caught instanceof Error ? caught.message : 'save_failed'); + } finally { + setSaving(false); + } + }, [validate, buildPayload, onSaved]); + + const runTest = useCallback(async () => { + setTesting(true); + setTestResult(null); + try { + const response = await testPicoclawModel(buildPayload()); + if (isOk(response)) { + setTestResult(response.data as PicoclawModelTestResult); + } else { + setTestResult({ ok: false, outcome: 'unknown' }); + } + } catch { + setTestResult({ ok: false, outcome: 'network_error' }); + } finally { + setTesting(false); + } + }, [buildPayload]); + + const endpointRequired = Boolean(currentPreset?.endpoint_required); + const endpointEditable = Boolean(currentPreset?.endpoint_editable); + const endpointVisible = endpointEditable && (endpointRequired || advanced); + + return { + loading, + providers, + config, + provider, + authMethod, + model, + apiBase, + apiKey, + advanced, + saving, + error, + testing, + testResult, + authStatus, + loginInfo, + deviceCode, + oauthBusy, + currentPreset, + presetModels: currentPreset?.models ?? [], + authMethods: currentPreset?.auth_methods ?? [AUTH_API_KEY], + endpointRequired, + endpointVisible, + apiKeyConfigured: Boolean(config?.api_key_configured), + setModel, + setApiBase, + setApiKey, + setDeviceCode, + setAdvanced, + setError, + changeProvider, + changeAuthMethod, + startOAuth, + submitOAuthCode, + logoutOAuth, + checkAuthStatus, + save, + runTest + }; +} diff --git a/web/src/pages/desktop/picoclaw/use-sidebar.ts b/web/src/pages/desktop/picoclaw/use-sidebar.ts index 33d062c2..80e36ba3 100644 --- a/web/src/pages/desktop/picoclaw/use-sidebar.ts +++ b/web/src/pages/desktop/picoclaw/use-sidebar.ts @@ -47,15 +47,11 @@ export const useSidebar = () => { status: '' }); - const [modelApiBase, setModelApiBase] = useState(''); - const [modelApiKey, setModelApiKey] = useState(''); - const [modelIdentifier, setModelIdentifier] = useState(''); const [isInitializing, setIsInitializing] = useState(true); const [isModelConfigOpen, setIsModelConfigOpen] = useState(false); const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [isInstallRequestPending, setIsInstallRequestPending] = useState(false); const [isUninstallRequestPending, setIsUninstallRequestPending] = useState(false); - const [isSavingModelConfig, setIsSavingModelConfig] = useState(false); const [isSwitchingAgent, setIsSwitchingAgent] = useState(false); const [isSwitchingSession, setIsSwitchingSession] = useState(false); const [isTogglingRuntime, setIsTogglingRuntime] = useState(false); @@ -86,9 +82,6 @@ export const useSidebar = () => { runtimeStatus, transportState, installSnapshot, - modelApiBase, - modelApiKey, - modelIdentifier, setRuntimeStatus, setMessages, setTakeover, @@ -99,7 +92,6 @@ export const useSidebar = () => { setIsTogglingRuntime, setIsInstallRequestPending, setInstallSnapshot, - setIsSavingModelConfig, setIsSwitchingAgent, setIsUninstallRequestPending }); @@ -186,16 +178,13 @@ export const useSidebar = () => { handleAgentProfileChange: actions.handleAgentProfileChange, handleCloseHistory: sessionActions.handleCloseHistory, handleDeleteHistorySession: sessionActions.handleDeleteHistorySession, - handleModelApiBaseChange: setModelApiBase, - handleModelApiKeyChange: setModelApiKey, - handleModelIdentifierChange: setModelIdentifier, + handleModelConfigSaved: actions.handleModelConfigSaved, handleNewConversation: sessionActions.handleNewConversation, handleOpenHistory: sessionActions.handleOpenHistory, handleOpenModelConfig: () => { setIsHistoryOpen(false); setIsModelConfigOpen(true); }, - handleSaveModelConfig: actions.handleSaveModelConfig, handleSelectHistorySession: sessionActions.handleSelectHistorySession, historySessions, isDeletingSession, @@ -208,13 +197,9 @@ export const useSidebar = () => { isUninstallingRuntime: isUninstallRequestPending, installProgress, installStage, - isSavingModelConfig, isSwitchingAgent, isTogglingRuntime, messages, - modelApiBase, - modelApiKey, - modelIdentifier, isModelConfigOpen, runState, runtimeStatus,