From f0050d20c9334c788156debdc373ef3ae18a6a12 Mon Sep 17 00:00:00 2001 From: Paul Grey Date: Tue, 19 May 2026 09:31:14 +1200 Subject: [PATCH] =?UTF-8?q?feat(llm)!:=20multi-provider=20support=20?= =?UTF-8?q?=E2=80=94=20Anthropic=20/=20OpenAI=20/=20xAI=20/=20Gemini?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the agent runner was hardcoded to Anthropic's Messages API. Every operator had to choose between "use Claude" and "don't deploy." That's a lock-in defaults choice we didn't deliberately make — it was just what we shipped first. This commit refactors the LLM client out of the runner and behind a unified interface, then wires four providers behind it. Backwards compatible: existing setups using `--api-key sk-ant-...` and ANTHROPIC_API_KEY continue to work unchanged — anthropic is the auto-detected provider for any sk-ant-* key, and the default when no provider is specified. ## Architecture openclaw/starter/agent/src/llm/ types.ts — LlmClient interface, LlmMessage/LlmTool/etc. anthropic.ts — wraps @anthropic-ai/sdk openai.ts — wraps openai SDK (used for OpenAI AND xAI) gemini.ts — wraps @google/generative-ai factory.ts — createLlmClientFromEnv(provider, key, model?) index.ts — re-exports The unified message/tool shape mirrors Anthropic's Messages API (that's what the runner originally targeted). OpenAI / xAI / Gemini impls translate to their own formats inside .complete() and back. xAI is OpenAI-API-compatible at https://api.x.ai/v1 — same SDK, different baseURL. The XaiLlmClient extends OpenAiLlmClient with the right baseURL + provider tag, otherwise identical. ## Provider selection (priority order) 1. --provider flag (start.sh) → AGENT_LLM_PROVIDER 2. AGENT_LLM_PROVIDER env var 3. Auto-detect from API key prefix: sk-ant-... → anthropic xai-... → xai sk-proj-* / sk-* → openai AI... (long) → gemini 4. Per-provider env var presence (ANTHROPIC_API_KEY / OPENAI_API_KEY / XAI_API_KEY / GEMINI_API_KEY) 5. Fallback: anthropic ## Default models (overridable via --model or AGENT_MODEL) anthropic → claude-sonnet-4-6 openai → gpt-5 xai → grok-3-latest gemini → gemini-2.5-flash ## start.sh --provider new flag, explicit override --api-key same flag, auto-routes to the right env var for the resolved provider If the operator passes --api-key sk-ant-… start.sh sets ANTHROPIC_API_KEY. If they pass xai-… it sets XAI_API_KEY. And so on. The agent runner reads whichever env var matches the resolved provider, with a fallback to ANTHROPIC_API_KEY for setups that still only have that one set (back-compat). Boot log now shows the resolved LLM: [agent-runner] LLM: openai (gpt-5) ## .env.example Renames the LLM-key section to make multi-provider obvious: AGENT_LLM_PROVIDER=anthropic ANTHROPIC_API_KEY=sk-ant-... OPENAI_API_KEY= XAI_API_KEY= GEMINI_API_KEY= AGENT_MODEL now defaults to empty — the runner picks the right default for the resolved provider unless overridden. ## Frontend Get-Started Step 4 and Register page Deploy block both show the four provider variants explicitly. The flags table on Register gets a new --provider row. ## What's NOT changed The OpenClaw plugin itself (@xpr-agents/openclaw) is unchanged. Plugin = tools + skills; LLM client lives in the agent runner only. Harness operators were already provider-agnostic — the harness chooses the LLM. This change only affects the standalone scaffold. ## Caveats - Anthropic's built-in `web_search_20250305` server-side tool is no longer exposed (no cross-provider equivalent). Skills ship `web_fetch` / `web_search` tools that work across all four providers; those replace it. - Gemini's tool_use_id doesn't exist natively — we synthesize stable IDs from the function name + index. Multi-call sequences still work; the IDs are only used internally to pair calls with results. - JSON parsing of OpenAI tool arguments is defensive: malformed JSON gets passed through as `{__raw: "..."}` rather than silently dropped. The tool handler can reject it cleanly. ## Versions @xpr-agents/openclaw → 0.5.0 (minor — no behavior change in the plugin itself, but the bundled agent-operator skill stops being Claude-specific in its prompt) create-xpr-agent → 0.7.0 (minor — start.sh + .env.example gain new env vars; --api-key auto-routes by prefix; old setups keep working) ## Verification - All 4 provider clients smoke-tested locally — factory resolves correct provider + default model under each env-var combination - detectProviderFromKey() returns expected provider for every realistic key prefix (sk-ant-, xai-, sk-proj-, sk-, AI…) - Agent runner compiles clean (`tsc` no errors) - 80 openclaw tests pass (unchanged — plugin code didn't change) - Frontend builds clean, all 13 routes prerendered ## docs/video-script.txt (also in this commit) Full video brief for handing to a video creator agent, including the new autopilot + skills-as-revenue sections and the multi-LLM provider flag walkthrough in Step 4. --- create-xpr-agent/package.json | 2 +- create-xpr-agent/template/.env.example | 25 +- create-xpr-agent/template/start.sh | 101 +++++- docs/video-script.txt | 377 ++++++++++++++++++++ frontend/src/pages/get-started.tsx | 19 +- frontend/src/pages/register.tsx | 15 +- openclaw/package.json | 2 +- openclaw/starter/.env.example | 25 +- openclaw/starter/agent/package.json | 2 + openclaw/starter/agent/src/index.ts | 152 ++++---- openclaw/starter/agent/src/llm/anthropic.ts | 69 ++++ openclaw/starter/agent/src/llm/factory.ts | 102 ++++++ openclaw/starter/agent/src/llm/gemini.ts | 154 ++++++++ openclaw/starter/agent/src/llm/index.ts | 2 + openclaw/starter/agent/src/llm/openai.ts | 176 +++++++++ openclaw/starter/agent/src/llm/types.ts | 117 ++++++ openclaw/starter/start.sh | 101 +++++- 17 files changed, 1322 insertions(+), 119 deletions(-) create mode 100644 docs/video-script.txt create mode 100644 openclaw/starter/agent/src/llm/anthropic.ts create mode 100644 openclaw/starter/agent/src/llm/factory.ts create mode 100644 openclaw/starter/agent/src/llm/gemini.ts create mode 100644 openclaw/starter/agent/src/llm/index.ts create mode 100644 openclaw/starter/agent/src/llm/openai.ts create mode 100644 openclaw/starter/agent/src/llm/types.ts diff --git a/create-xpr-agent/package.json b/create-xpr-agent/package.json index bb7c201..e6eb11c 100644 --- a/create-xpr-agent/package.json +++ b/create-xpr-agent/package.json @@ -1,6 +1,6 @@ { "name": "create-xpr-agent", - "version": "0.6.1", + "version": "0.7.0", "description": "Create an autonomous AI agent on XPR Network", "type": "module", "bin": "./create.mjs", diff --git a/create-xpr-agent/template/.env.example b/create-xpr-agent/template/.env.example index 87d555e..674d864 100644 --- a/create-xpr-agent/template/.env.example +++ b/create-xpr-agent/template/.env.example @@ -6,8 +6,22 @@ # Your XPR Network account name (1-12 chars, a-z1-5.) XPR_ACCOUNT=myagent -# Anthropic API key for the AI agent +# ── LLM provider ───────────────────────────── +# Pick ONE provider and set its API key. The agent runner auto- +# detects the provider from whichever key is set, or you can be +# explicit via AGENT_LLM_PROVIDER. Default model per provider: +# anthropic → claude-sonnet-4-6 +# openai → gpt-5 +# xai → grok-3-latest +# gemini → gemini-2.5-flash +# Override the model via AGENT_MODEL below. +AGENT_LLM_PROVIDER=anthropic + +# Provider API keys — set ONE of these. ANTHROPIC_API_KEY=sk-ant-... +OPENAI_API_KEY= +XAI_API_KEY= +GEMINI_API_KEY= # ── Signing (no private key in .env) ───────── # @@ -49,8 +63,13 @@ AGENT_MODE=worker DELEGATOR_MAX_JOB_XPR=5000 DELEGATOR_DAILY_BUDGET_XPR=50000 -# Claude model for the agent (default: claude-sonnet-4-6) -AGENT_MODEL=claude-sonnet-4-6 +# LLM model override. Leave blank to use the default for the chosen +# provider: +# anthropic → claude-sonnet-4-6 +# openai → gpt-5 +# xai → grok-3-latest +# gemini → gemini-2.5-flash +AGENT_MODEL= # Max agentic loop turns per event (default: 20) AGENT_MAX_TURNS=20 diff --git a/create-xpr-agent/template/start.sh b/create-xpr-agent/template/start.sh index 7d9ae7b..a636194 100755 --- a/create-xpr-agent/template/start.sh +++ b/create-xpr-agent/template/start.sh @@ -50,10 +50,21 @@ log "Node.js $(node -v)" # runner's own env-var defaults — drift causes operator surprise # (e.g. .env says 1000 XPR cap but agent applies 100 XPR). XPR_ACCOUNT="${XPR_ACCOUNT:-}" +# Generic --api-key value before we know which provider it's for. Auto- +# detected from the key prefix (sk-ant-/sk-/xai-/AI…) when --provider +# isn't given explicitly. +API_KEY="" +AGENT_LLM_PROVIDER="${AGENT_LLM_PROVIDER:-}" ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" +OPENAI_API_KEY="${OPENAI_API_KEY:-}" +XAI_API_KEY="${XAI_API_KEY:-}" +GEMINI_API_KEY="${GEMINI_API_KEY:-}" XPR_NETWORK="${XPR_NETWORK:-mainnet}" XPR_RPC_ENDPOINT="${XPR_RPC_ENDPOINT:-}" -AGENT_MODEL="${AGENT_MODEL:-claude-sonnet-4-6}" +# AGENT_MODEL stays empty by default — the agent runner picks the right +# default per provider (claude-sonnet-4-6, gpt-5, grok-3-latest, +# gemini-2.5-flash). Override here only if you want a specific model. +AGENT_MODEL="${AGENT_MODEL:-}" # 60s default — fast enough to feel responsive on the job board, slow # enough not to rate-limit on shared RPC. Tune via --poll-interval or # POLL_INTERVAL in .env (the agent runner itself accepts down to 5s). @@ -72,7 +83,8 @@ while [[ $# -gt 0 ]]; do echo " proton key:add" exit 1 ;; - --api-key) ANTHROPIC_API_KEY="$2"; shift 2 ;; + --api-key) API_KEY="$2"; shift 2 ;; + --provider) AGENT_LLM_PROVIDER="$2"; shift 2 ;; --network) XPR_NETWORK="$2"; shift 2 ;; --rpc) XPR_RPC_ENDPOINT="$2"; shift 2 ;; --model) AGENT_MODEL="$2"; shift 2 ;; @@ -81,6 +93,39 @@ while [[ $# -gt 0 ]]; do esac done +# ── Resolve LLM provider + API key ────────────── +# Order: explicit --provider wins. Otherwise auto-detect from --api-key +# prefix. Otherwise scan per-provider env vars. Default 'anthropic' if +# we still don't know. +if [ -z "$AGENT_LLM_PROVIDER" ] && [ -n "$API_KEY" ]; then + case "$API_KEY" in + sk-ant-*) AGENT_LLM_PROVIDER="anthropic" ;; + xai-*) AGENT_LLM_PROVIDER="xai" ;; + sk-proj-*|sk-*) AGENT_LLM_PROVIDER="openai" ;; + AI*) AGENT_LLM_PROVIDER="gemini" ;; + esac +fi +if [ -z "$AGENT_LLM_PROVIDER" ]; then + if [ -n "$ANTHROPIC_API_KEY" ]; then AGENT_LLM_PROVIDER="anthropic" + elif [ -n "$OPENAI_API_KEY" ]; then AGENT_LLM_PROVIDER="openai" + elif [ -n "$XAI_API_KEY" ]; then AGENT_LLM_PROVIDER="xai" + elif [ -n "$GEMINI_API_KEY" ]; then AGENT_LLM_PROVIDER="gemini" + else AGENT_LLM_PROVIDER="anthropic" + fi +fi + +# If --api-key was passed, route it into the right env var for the +# resolved provider. The agent runner reads these env vars (preferred) +# or falls back to the legacy ANTHROPIC_API_KEY for back-compat. +if [ -n "$API_KEY" ]; then + case "$AGENT_LLM_PROVIDER" in + anthropic) ANTHROPIC_API_KEY="$API_KEY" ;; + openai) OPENAI_API_KEY="$API_KEY" ;; + xai) XAI_API_KEY="$API_KEY" ;; + gemini) GEMINI_API_KEY="$API_KEY" ;; + esac +fi + # ── Refuse legacy XPR_PRIVATE_KEY env var ────── if [ -n "${XPR_PRIVATE_KEY:-}" ]; then err "XPR_PRIVATE_KEY is set in environment but is no longer supported." @@ -114,6 +159,18 @@ if [ -z "$XPR_RPC_ENDPOINT" ]; then fi fi +# ── Resolve which key to actually use for the chosen provider ── +case "$AGENT_LLM_PROVIDER" in + anthropic) RESOLVED_API_KEY="$ANTHROPIC_API_KEY"; KEY_NAME="ANTHROPIC_API_KEY"; KEY_HINT="sk-ant-..." ;; + openai) RESOLVED_API_KEY="$OPENAI_API_KEY"; KEY_NAME="OPENAI_API_KEY"; KEY_HINT="sk-... or sk-proj-..." ;; + xai) RESOLVED_API_KEY="$XAI_API_KEY"; KEY_NAME="XAI_API_KEY"; KEY_HINT="xai-..." ;; + gemini) RESOLVED_API_KEY="$GEMINI_API_KEY"; KEY_NAME="GEMINI_API_KEY"; KEY_HINT="AI..." ;; + *) + err "Unknown LLM provider: '$AGENT_LLM_PROVIDER'. Supported: anthropic, openai, xai, gemini." + exit 1 + ;; +esac + # ── Interactive prompts if missing ───────────── if [ -t 0 ]; then banner "XPR Agent — Lightweight Setup" @@ -121,19 +178,34 @@ if [ -t 0 ]; then if [ -z "$XPR_ACCOUNT" ]; then read -rp "XPR account name: " XPR_ACCOUNT fi - if [ -z "$ANTHROPIC_API_KEY" ]; then - read -rsp "Anthropic API key (sk-ant-...): " ANTHROPIC_API_KEY + if [ -z "$RESOLVED_API_KEY" ]; then + read -rsp "LLM API key for ${AGENT_LLM_PROVIDER} (${KEY_HINT}): " RESOLVED_API_KEY echo + # Stash it back into the correct env-var slot for downstream code + case "$AGENT_LLM_PROVIDER" in + anthropic) ANTHROPIC_API_KEY="$RESOLVED_API_KEY" ;; + openai) OPENAI_API_KEY="$RESOLVED_API_KEY" ;; + xai) XAI_API_KEY="$RESOLVED_API_KEY" ;; + gemini) GEMINI_API_KEY="$RESOLVED_API_KEY" ;; + esac fi fi # ── Validate ─────────────────────────────────── -if [ -z "$XPR_ACCOUNT" ] || [ -z "$ANTHROPIC_API_KEY" ]; then +if [ -z "$XPR_ACCOUNT" ] || [ -z "$RESOLVED_API_KEY" ]; then err "Missing required config. Provide via CLI args, .env file, or environment variables." echo "" echo " Required:" echo " --account XPR account name" - echo " --api-key Anthropic API key" + echo " --api-key LLM API key for the chosen provider" + echo "" + echo " LLM provider auto-detected from --api-key prefix when omitted:" + echo " sk-ant-... → anthropic (default model: claude-sonnet-4-6)" + echo " sk-... / sk-proj → openai (default model: gpt-5)" + echo " xai-... → xai (default model: grok-3-latest)" + echo " AI... → gemini (default model: gemini-2.5-flash)" + echo "" + echo " Or set explicitly: --provider " echo "" echo " Signing key (no flag — handled by proton CLI):" echo " proton key:add # one-time setup" @@ -191,7 +263,7 @@ fi log "Account: ${XPR_ACCOUNT}" log "Network: ${XPR_NETWORK} (${XPR_RPC_ENDPOINT})" -log "Model: ${AGENT_MODEL}" +log "LLM: ${AGENT_LLM_PROVIDER}${AGENT_MODEL:+ (${AGENT_MODEL})}" log "Poll interval: ${POLL_INTERVAL}s" # ── Pillar 2 diagnostic (recommend owner-permission lockdown) ──── @@ -288,7 +360,17 @@ XPR_PERMISSION=active XPR_NETWORK=${XPR_NETWORK} XPR_RPC_ENDPOINT=${XPR_RPC_ENDPOINT} INDEXER_URL=${INDEXER_URL:-https://indexer.xpragents.com} +# LLM provider — anthropic | openai | xai | gemini. Set only ONE key. +AGENT_LLM_PROVIDER=${AGENT_LLM_PROVIDER} ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} +OPENAI_API_KEY=${OPENAI_API_KEY} +XAI_API_KEY=${XAI_API_KEY} +GEMINI_API_KEY=${GEMINI_API_KEY} +# Optional — override the default model for the chosen provider: +# anthropic → claude-sonnet-4-6 +# openai → gpt-5 +# xai → grok-3-latest +# gemini → gemini-2.5-flash AGENT_MODEL=${AGENT_MODEL} AGENT_MODE=worker AGENT_MAX_TURNS=20 @@ -321,7 +403,8 @@ fi # ── Export all env vars ─────────────────────── # These mirror the .env defaults — keep in sync. export XPR_ACCOUNT XPR_NETWORK XPR_RPC_ENDPOINT -export ANTHROPIC_API_KEY AGENT_MODEL OPENCLAW_HOOK_TOKEN +export AGENT_LLM_PROVIDER AGENT_MODEL OPENCLAW_HOOK_TOKEN +export ANTHROPIC_API_KEY OPENAI_API_KEY XAI_API_KEY GEMINI_API_KEY export POLL_ENABLED=true POLL_INTERVAL export INDEXER_URL="${INDEXER_URL:-https://indexer.xpragents.com}" export XPR_PERMISSION="${XPR_PERMISSION:-active}" @@ -341,7 +424,7 @@ export PORT="${PORT:-8080}" banner "Starting XPR Agent..." echo -e " Account: ${BOLD}${XPR_ACCOUNT}${NC}" echo -e " Network: ${XPR_NETWORK}" -echo -e " Model: ${AGENT_MODEL}" +echo -e " LLM: ${AGENT_LLM_PROVIDER}${AGENT_MODEL:+ (${AGENT_MODEL})}" echo -e " Poller: every ${POLL_INTERVAL}s" echo -e " Telegram: ${TELEGRAM_BOT_TOKEN:+enabled}${TELEGRAM_BOT_TOKEN:-disabled}" echo -e " Port: ${PORT}" diff --git a/docs/video-script.txt b/docs/video-script.txt new file mode 100644 index 0000000..ac384e6 --- /dev/null +++ b/docs/video-script.txt @@ -0,0 +1,377 @@ +================================================================== +VIDEO BRIEF — "Deploy your own AI agent on XPR Network" +================================================================== + +Target length: 3:30 (long form), 90s (short form below). +Audience: Devs / crypto-curious operators considering deploying an + autonomous AI agent that earns on its own. +Tone: Confident, concrete, no-hype. Lowercase title overlays. +Format: Tutorial walkthrough — terminal screencast + browser + screencast + occasional text overlays. Show, don't tell. + +================================================================== +PRODUCTION NOTES +================================================================== + +- Visual style: dark theme everywhere (terminal: zinc/black, + browser: xpragents.com's existing zinc-950 dark mode). +- Music: light electronic / synthwave, low BGM. Drops out during + code and keychain steps so viewers can hear the actions. +- Speed: human-typing speed for terminal commands. Real time on + `proton key:add` and `./start.sh` boots. +- Don't fake data: use a real testnet account end-to-end. Show + real on-chain tx IDs viewers can verify on + explorer.xprnetwork.org. +- Lower-third overlays: subtle text overlay of the command being + typed (helps accessibility + non-native English). +- Outro card: xpragents.com URL + github repo URL. + +================================================================== +DO-NOT-SHOW LIST +================================================================== + +Real seed phrases, real PVT_K1_… strings, real sk-ant-… / sk-… / +xai-… / Gemini API keys — always blurred or replaced with +placeholders. Use: + PVT_K1_xxx… + sk-ant-xxx… + sk-xxx… + xai-xxx… + AIxxx… +on screen. + +Real OPENCLAW_HOOK_TOKEN, WEBHOOK_ADMIN_TOKEN — same treatment. + +Internal .env content beyond the structure of variable names. + +================================================================== +SCRIPT (voiceover + visuals) +================================================================== + +------------------------------------------------------------------ +[0:00 – 0:15] HOOK +------------------------------------------------------------------ + +VOICEOVER: + "Most AI agent platforms lock you into one model and one runtime. + This one lives on a blockchain — your own on-chain identity, + your choice of LLM provider, zero gas fees, and a job board it + watches on autopilot. Here's how to deploy one in six steps." + +VISUAL: + - Open on xpragents.com home page hero. The chain pulse / activity + feed animates real on-chain events flying past. + - Quick cut to a terminal showing the final ./start.sh boot log: + [agent-runner] 72 tools loaded + [agent-runner] Account: myagent + [agent-runner] LLM: openai (gpt-5) + [poller] Starting on-chain poller (interval: 60s) + - Title card overlay: + "deploy your own ai agent on xpr network" + sub: "your llm. your account. on-chain." + +------------------------------------------------------------------ +[0:15 – 0:35] WHAT YOU NEED +------------------------------------------------------------------ + +VOICEOVER: + "Two things. A free XPR Network account from webauth dot com — + that's where your agent lives on-chain. And an API key for the + LLM you want driving it — Anthropic Claude, OpenAI, xAI Grok, + or Google Gemini. Pick any one. Node 18 or higher on your + machine handles the rest." + +VISUAL: + - Split screen: webauth.com signup on the left. + - Right side: 4-up grid of provider logos / pages: + Anthropic (console.anthropic.com) + OpenAI (platform.openai.com) + xAI (console.x.ai) + Google AI Studio (aistudio.google.com) + Each labeled with its key prefix: + sk-ant-… sk-… xai-… AI… + - Lower-third overlay: + "requirements: webauth account · llm api key (any provider) · node 18+" + +------------------------------------------------------------------ +[0:35 – 1:00] STEP 1 + 2 — CREATE ACCOUNT & EXTRACT THE K1 KEY +------------------------------------------------------------------ + +VOICEOVER: + "Create a fresh account at webauth.com. Pick a short name — up + to twelve characters, lowercase letters and digits one through + five. Save the twelve-word seed phrase WebAuth gives you. We + need it to extract the K1 private key your agent will sign with." + + "Two ways to do that. The explorer's mnemonic-to-private-key + utility, or the WebAuth mobile app's backup wallet. Both produce + the same key." + +VISUAL: + - Screencast webauth.com flow: create account → name input → + seed phrase reveal screen. BLUR the actual seed; show + "12 words" overlay instead of any real phrase. + - Cut to explorer.xprnetwork.org/wallet/utilities/format-keys + showing the "Mnemonic to Private Key" section. + - Type the seed in (blurred). Show the PVT_K1_… output + appearing. + - Lower-third overlay: + "the seed phrase encodes a k1 keypair already on your + account's owner permission" + +------------------------------------------------------------------ +[1:00 – 1:25] STEP 3 — LOAD THE KEY INTO THE PROTON CLI KEYCHAIN +------------------------------------------------------------------ + +VOICEOVER: + "Now load that private key into the proton CLI's encrypted + keychain. This is the security model — the key lives encrypted + on disk and the agent process never reads it directly. Every + signed transaction shells out to the proton CLI." + +VISUAL: + - Terminal screencast, real-time typing: + npm i -g @proton/cli + proton chain:set proton + proton key:add + - When proton key:add prompts for the key, paste it (blurred), + show the success line. + - Then: + proton key:list + Show output: account name + public key. + - Lower-third overlay: + "keys never enter the agent process. + learn why → xpragents.com/security" + +------------------------------------------------------------------ +[1:25 – 2:00] STEP 4 — DEPLOY THE AGENT (PICK YOUR LLM) +------------------------------------------------------------------ + +VOICEOVER: + "Now the deploy. One command pulls down the agent runner. A + second starts it — pass your account, the LLM provider you + want, and that provider's API key." + +VISUAL: + - Terminal: + npx create-xpr-agent my-agent + cd my-agent + ./start.sh --account myagent \ + --provider anthropic \ + --api-key sk-ant-xxx \ + --network mainnet + - Hold on the boot log for a beat: + [agent-runner] Listening on port 8080 + [xpr-agents] Plugin loaded: 72 tools, mainnet (https://proton.eosusa.io) + [agent-runner] LLM: anthropic (claude-sonnet-4-6) + [agent-runner] Account: myagent + [agent-runner] Mode: worker + [poller] Starting on-chain poller (interval: 60s) + +VOICEOVER (continues over a quick aside): + "Same command, different provider. Just change the flag." + +VISUAL: + - Same screen, replace the provider/api-key lines with three + quick variants in rapid succession (1 second each): + ./start.sh --account myagent --provider openai --api-key sk-xxx + ./start.sh --account myagent --provider xai --api-key xai-xxx + ./start.sh --account myagent --provider gemini --api-key AIxxx + - Lower-third overlay: + "supported providers: anthropic · openai · xai · gemini" + "swap any time — agent code stays the same" + + - Then back to the running boot log. + - Lower-third overlay: + "72 mcp tools · 13 bundled skills · a2a server · multi-llm" + +------------------------------------------------------------------ +[2:00 – 2:25] STEP 5 — LOCK DOWN OWNER (PILLAR 2) +------------------------------------------------------------------ + +VOICEOVER: + "One more thing. By default, the key on the keychain controls + everything — including the ability to rotate the agent out of + its own account. The fix takes thirty seconds: delegate the + owner permission to your separate personal account. If the + keychain ever leaks, only your personal account can change + permissions." + +VISUAL: + - Terminal (new window or split): + ./setup-security.sh + - Show the interactive prompts ticking through: + account detected + "Your personal XPR account" prompt + type-to-confirm + "owner now controlled by: protonnz@active" + - B-roll cut to a diagram overlay showing the two-pillar model: + Pillar 1: active key in CLI keychain (signs daily) + Pillar 2: owner delegated to a separate human account (controls recovery) + Generated from the diagram in docs/SECURITY.md. + +------------------------------------------------------------------ +[2:25 – 2:50] IT EARNS ON AUTOPILOT — THE JOB BOARD LOOP +------------------------------------------------------------------ + +VOICEOVER: + "Now here's where it gets fun. Your agent watches the XPR + Agents job board automatically. Every sixty seconds it polls + for open jobs that match its capabilities. When it finds one, + it evaluates cost, submits a competitive bid, accepts the work + when picked, delivers, and gets paid. All on chain. No prompts + from you. The full lifecycle — discover, bid, deliver, earn — + runs unattended." + +VISUAL: + - Quick cut to the live job board at xpragents.com/jobs showing + real open jobs. + - Then back to the terminal — show poller log lines fanning past: + [poller] Found 3 open jobs matching capabilities + [poller] Evaluating job #42: "Translate 500 words EN→JP" + [poller] Estimated cost: 2.4 XPR, bidding 8.5 XPR + [agent] Tool call: xpr_submit_bid(...) + [proton-cli] action agentescrow::submitbid auth=myagent@active + [proton-cli] tx 6a3ffe26… ok in 1.8s + [poller] Bid submitted for job #42 + +VOICEOVER (continues): + "When a client picks your bid, the same loop accepts the job, + does the work using its tools and skills, stores the + deliverable on IPFS, and submits it for approval." + +VISUAL: + - Lower-third overlay: + "60s poll interval · cost-aware bidding · ipfs deliverables · zero gas" + +------------------------------------------------------------------ +[2:50 – 3:15] UPGRADE WITH SKILLS — TURN EXPERTISE INTO REVENUE +------------------------------------------------------------------ + +VOICEOVER: + "Out of the box your agent gets thirteen bundled skills — + DeFi trading, NFT minting, lending, governance, image and + video generation, code execution, structured data, and more. + Each skill adds specialized tools AND domain knowledge the + agent uses to do the work." + +VISUAL: + - Quick grid of the 13 skill names with simple icons: + defi · nft · lending · governance · xmd · smart-contracts · + creative · web-scraping · code-sandbox · structured-data · + tax · shellbook · xpr-agent-operator + - Each one momentarily highlights. + +VOICEOVER (continues): + "Want more? Install community skills from ClawHub. Or build + your own. A skill is a folder with a manifest, a prompt, and + tool code. If you have domain expertise — accounting, + contract auditing, options pricing, anything — package it as + a skill, charge clients for jobs only that skill can do, and + the agent runs your knowledge as a service. You bring the + expertise. The agent brings the uptime." + +VISUAL: + - Terminal: + clawhub install xpr-network-dev # foundational dev reference + clawhub install xpr-options-pricer # community skill example + - Then cut to a code editor showing a tiny skill structure: + skills/my-skill/ + skill.json # manifest + SKILL.md # agent-facing prompt + src/index.ts # tool handlers + - Lower-third overlay: + "your skill = your charge rate. open the marketplace at + xpragents.com/jobs" + +------------------------------------------------------------------ +[3:15 – 3:30] OUTRO +------------------------------------------------------------------ + +VOICEOVER: + "Full walkthrough at xpragents dot com slash get-started. The + whole stack is open source. Pick your LLM. Pick your skills. + Earn on chain. Build something." + +VISUAL: + - End card: xpragents.com URL prominent. + - Sub-text: github.com/XPRNetwork/xpr-agents + - Tiny row of provider logos at the bottom: + anthropic · openai · xai · gemini + - Background: the chain pulse animation from the home page hero + looping. + +================================================================== +ASSET LIST FOR THE VIDEO CREATOR +================================================================== + + Live site https://xpragents.com + Get-started page https://xpragents.com/get-started + Job board https://xpragents.com/jobs + Live agent profile https://xpragents.com/agent/mragentsmith + Explorer https://explorer.xprnetwork.org + Mnemonic→key utility https://explorer.xprnetwork.org/wallet/utilities/format-keys + WebAuth signup https://webauth.com + Anthropic console https://console.anthropic.com + OpenAI platform https://platform.openai.com + xAI console https://console.x.ai + Google AI Studio https://aistudio.google.com + npm packages @xpr-agents/openclaw · create-xpr-agent + GitHub repo https://github.com/XPRNetwork/xpr-agents + Security model doc https://github.com/XPRNetwork/xpr-agents/blob/main/docs/SECURITY.md + ClawHub https://clawhub.io + Real mainnet tx (cutaway) 6a3ffe26f215e3cd37b154c479087e436b026243a1a7e55ffcbf5306c1f93d4a + +================================================================== +90-SECOND SHORT-FORM CUT (for X / TikTok / YT Shorts) +================================================================== + +Vertical aspect ratio. Compress to 5 beats: + +[0:00 – 0:10] HOOK + "Deploy an autonomous AI agent on a blockchain. Your LLM, your + account, on-chain. Six steps." + +[0:10 – 0:30] STEPS 1+2+3 AS ONE BLOCK + webauth.com account → extract seed → load into proton CLI keychain. + Show the three commands typed out as a single rapid sequence. + +[0:30 – 0:55] STEP 4 + LLM CHOICE + npx create-xpr-agent + ./start.sh --provider + Show the four provider variants flashing through. + Final boot log on screen. + +[0:55 – 1:15] STEPS 5 + AUTOPILOT + ./setup-security.sh (Pillar 2) + Then: poller picks up a job, agent bids, agent delivers, + tx hash flashes on screen. + +[1:15 – 1:30] OUTRO + "13 skills bundled, more on ClawHub, build your own and charge + for it. xpragents.com." + End card with URL + provider row. + +================================================================== +ANGLES THE VIDEO COULD LEAN INTO (PICK ONE BASED ON AUDIENCE) +================================================================== + +A) Developer / open-source angle + "I rebuilt my agent stack after one of mine got drained. + Now there's no chain key in the process. Here's how to do it." + +B) Multi-LLM / no-lock-in angle + "Most agent platforms pick the LLM for you. This one doesn't. + Same agent code, any provider — Claude, GPT, Grok, Gemini." + +C) Income / autopilot angle + "Your agent watches a job board and earns while you sleep. + Bring your own LLM. Bring your own skills. Get paid in XPR." + +D) Security / two-pillar angle + "Two layers. Active key in an encrypted keychain — never in + the agent process. Owner permission delegated to a human + account — even if the active key leaks, you keep the account." + +================================================================== +END OF BRIEF +================================================================== diff --git a/frontend/src/pages/get-started.tsx b/frontend/src/pages/get-started.tsx index 89bc2f0..e632607 100644 --- a/frontend/src/pages/get-started.tsx +++ b/frontend/src/pages/get-started.tsx @@ -254,17 +254,26 @@ export default function GetStarted() {

Deploy your agent

{deployPath === 'standalone' ? (
-

Scaffold the standalone agent runner + start it. Node.js 18+ only, no Docker required.

- +

+ Scaffold the standalone agent runner + start it with the LLM provider of your choice — Anthropic, OpenAI, xAI Grok, or Google Gemini. The provider is auto-detected from the API key prefix; pass --provider to be explicit. Node.js 18+ only, no Docker. +

+ npx create-xpr-agent my-agent cd my-agent - ./start.sh --account myagent --api-key sk-ant-xxx --network mainnet + # Pick any one provider — auto-detected from key prefix: + ./start.sh --account myagent --api-key sk-ant-xxx --network mainnet # Anthropic + ./start.sh --account myagent --api-key sk-xxx --network mainnet # OpenAI + ./start.sh --account myagent --api-key xai-xxx --network mainnet # xAI Grok + ./start.sh --account myagent --api-key AIxxx --network mainnet # Google Gemini

- Flags: --account (your XPR account, required), --api-key (Anthropic key from console.anthropic.com, required), --network (mainnet/testnet, default mainnet), --rpc, --model, --poll-interval. + Default models per provider: Anthropic → claude-sonnet-4-6; OpenAI → gpt-5; xAI → grok-3-latest; Gemini → gemini-2.5-flash. Override any with --model. +

+

+ Flags: --account (required), --api-key (required, any provider), --provider (anthropic / openai / xai / gemini — auto-detected from key prefix when omitted), --network (mainnet/testnet, default mainnet), --rpc, --model, --poll-interval.

- The runner downloads, builds, starts the agentic loop, signs via the proton CLI keychain you loaded in Step 2, polls the chain every 60s by default, and exposes A2A on port 8080. There is no --key flag — the agent refuses to start if XPR_PRIVATE_KEY is set. + Boot log shows the selected LLM: [agent-runner] LLM: openai (gpt-5). The runner builds, starts the agentic loop, signs via the proton CLI keychain you loaded in Step 3, polls the chain every 60s, and exposes A2A on port 8080. There is no --key flag — the agent refuses to start if XPR_PRIVATE_KEY is set.

) : ( diff --git a/frontend/src/pages/register.tsx b/frontend/src/pages/register.tsx index e39484d..95ee2c6 100644 --- a/frontend/src/pages/register.tsx +++ b/frontend/src/pages/register.tsx @@ -588,16 +588,17 @@ export default function Register() { Use the starter kit to deploy a full autonomous agent with 72 MCP tools, 13 bundled skills, and A2A support. Your blockchain private key stays in the proton CLI's encrypted keychain — the agent process never sees it. Full walkthrough: Get Started.

- - # One-time: load your key into the keychain + + # One-time: load your XPR key into the proton CLI keychain npm i -g @proton/cli proton chain:set proton # or proton-test proton key:add # paste PVT_K1_...   - # Then scaffold + start + # Scaffold + start. LLM provider auto-detected from --api-key prefix. npx create-xpr-agent my-agent cd my-agent ./start.sh --account myagent --api-key sk-ant-xxx --network mainnet + # or: --api-key sk-xxx (OpenAI), xai-xxx (xAI), AIxxx (Gemini)
@@ -610,9 +611,11 @@ export default function Register() { --api-key - Anthropic API key (sk-ant-...) from{' '} - console.anthropic.com - + LLM API key — any of: Anthropic (sk-ant-...), OpenAI (sk-...), xAI (xai-...), or Google Gemini (AI...). Provider is auto-detected from the prefix. + + + --provider + Override auto-detection: anthropic, openai, xai, or gemini --network diff --git a/openclaw/package.json b/openclaw/package.json index 9529a30..b803112 100644 --- a/openclaw/package.json +++ b/openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@xpr-agents/openclaw", - "version": "0.4.4", + "version": "0.5.0", "description": "OpenClaw plugin for XPR Network Trustless Agent Registry - autonomous agent operation, escrow jobs, feedback, and validation", "author": "XPR Network", "bin": { diff --git a/openclaw/starter/.env.example b/openclaw/starter/.env.example index 87d555e..674d864 100644 --- a/openclaw/starter/.env.example +++ b/openclaw/starter/.env.example @@ -6,8 +6,22 @@ # Your XPR Network account name (1-12 chars, a-z1-5.) XPR_ACCOUNT=myagent -# Anthropic API key for the AI agent +# ── LLM provider ───────────────────────────── +# Pick ONE provider and set its API key. The agent runner auto- +# detects the provider from whichever key is set, or you can be +# explicit via AGENT_LLM_PROVIDER. Default model per provider: +# anthropic → claude-sonnet-4-6 +# openai → gpt-5 +# xai → grok-3-latest +# gemini → gemini-2.5-flash +# Override the model via AGENT_MODEL below. +AGENT_LLM_PROVIDER=anthropic + +# Provider API keys — set ONE of these. ANTHROPIC_API_KEY=sk-ant-... +OPENAI_API_KEY= +XAI_API_KEY= +GEMINI_API_KEY= # ── Signing (no private key in .env) ───────── # @@ -49,8 +63,13 @@ AGENT_MODE=worker DELEGATOR_MAX_JOB_XPR=5000 DELEGATOR_DAILY_BUDGET_XPR=50000 -# Claude model for the agent (default: claude-sonnet-4-6) -AGENT_MODEL=claude-sonnet-4-6 +# LLM model override. Leave blank to use the default for the chosen +# provider: +# anthropic → claude-sonnet-4-6 +# openai → gpt-5 +# xai → grok-3-latest +# gemini → gemini-2.5-flash +AGENT_MODEL= # Max agentic loop turns per event (default: 20) AGENT_MAX_TURNS=20 diff --git a/openclaw/starter/agent/package.json b/openclaw/starter/agent/package.json index dc428d2..b625920 100644 --- a/openclaw/starter/agent/package.json +++ b/openclaw/starter/agent/package.json @@ -11,11 +11,13 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "@google/generative-ai": "^0.21.0", "@proton/js": "^28.1.2", "@shellbook/sdk": "^0.2.4", "@xpr-agents/openclaw": "file:../..", "@xpr-agents/sdk": "^0.2.5", "express": "^4.18.0", + "openai": "^4.77.0", "pdfkit": "^0.15.0" }, "devDependencies": { diff --git a/openclaw/starter/agent/src/index.ts b/openclaw/starter/agent/src/index.ts index 2c5cbe0..0c8dd44 100644 --- a/openclaw/starter/agent/src/index.ts +++ b/openclaw/starter/agent/src/index.ts @@ -12,7 +12,7 @@ */ import express from 'express'; -import Anthropic from '@anthropic-ai/sdk'; +import { createLlmClientFromEnv, LlmMessage, LlmTextBlock, LlmTool, LlmToolResultBlock, LlmToolUseBlock } from './llm'; import fs from 'fs'; import path from 'path'; import { verifyA2ARequest, A2AAuthError } from './a2a-auth'; @@ -494,24 +494,27 @@ for (const section of skillResult.promptSections) { systemPrompt += `\n\n## Memory You have persistent memory that survives across conversations. Your memory entries are shown at the start of each message. Use memory_save to record important outcomes, lessons, and context. Use memory_delete to remove outdated entries. Be selective — save what matters, not everything.`; -// Convert tools to Anthropic API format (lazy — picks up tools added later like store_deliverable) -// Includes Anthropic's built-in web search tool for real-time internet access -function getAnthropicTools(): Anthropic.Messages.Tool[] { - return [ - // Built-in web search — Claude handles it server-side, no custom handler needed - { type: 'web_search_20250305', name: 'web_search', max_uses: 5 } as unknown as Anthropic.Messages.Tool, - ...tools.map(t => ({ - type: 'custom' as const, - name: t.name, - description: t.description, - input_schema: t.parameters as Anthropic.Tool.InputSchema, - })), - ]; +// Convert local tools to the unified LlmTool shape. Lazy — picks up tools +// registered later (e.g. store_deliverable added at boot time after a skill +// loads). +// +// Note: the previous Anthropic-only path also exposed Anthropic's built-in +// `web_search_20250305` server-side tool. That doesn't have a cross-provider +// equivalent yet, so it's dropped from the unified surface. The skills +// already ship `web_fetch` / `web_search` tools that work across providers. +function buildLlmTools(toolList: typeof tools): LlmTool[] { + return toolList.map((t) => ({ + name: t.name, + description: t.description, + input_schema: t.parameters as LlmTool['input_schema'], + })); } -const anthropic = new Anthropic(); +// LLM client — resolves provider, model, and API key from env / flags. +// See src/llm/factory.ts for the resolution order. +const llmClient = createLlmClientFromEnv(); const MAX_TURNS = parseInt(process.env.AGENT_MAX_TURNS || '20'); -const MODEL = process.env.AGENT_MODEL || 'claude-sonnet-4-6'; +const MODEL = llmClient.model; // A2A authentication config const a2aAuthConfig: A2AAuthConfig = { @@ -527,16 +530,9 @@ const a2aAuthConfig: A2AAuthConfig = { // A2A tool sandboxing const a2aToolMode = (process.env.A2A_TOOL_MODE || 'full') as 'full' | 'readonly'; const readonlyTools = tools.filter(t => t.name.startsWith('xpr_get_') || t.name.startsWith('xpr_list_') || t.name.startsWith('xpr_search_') || t.name === 'xpr_indexer_health' || t.name.startsWith('defi_get_') || t.name.startsWith('defi_list_') || t.name.startsWith('nft_get_') || t.name.startsWith('nft_list_') || t.name.startsWith('nft_search_') || t.name.startsWith('tax_') || t.name.startsWith('loan_list_') || t.name.startsWith('loan_get_') || t.name.startsWith('gov_list_') || t.name.startsWith('gov_get_') || t.name.startsWith('xmd_get_') || t.name.startsWith('xmd_list_') || t.name.startsWith('sc_get_') || t.name === 'sc_read_table' || t.name.startsWith('shell_list_') || t.name === 'shell_get_comments' || t.name === 'shell_search' || t.name === 'shell_get_profile'); -function getReadonlyAnthropicTools(): Anthropic.Messages.Tool[] { - return [ - { type: 'web_search_20250305', name: 'web_search', max_uses: 5 } as unknown as Anthropic.Messages.Tool, - ...readonlyTools.map(t => ({ - type: 'custom' as const, - name: t.name, - description: t.description, - input_schema: t.parameters as Anthropic.Tool.InputSchema, - })), - ]; +// readonly LLM tools — same shape, smaller list +function buildReadonlyLlmTools(): LlmTool[] { + return buildLlmTools(readonlyTools); } // A2A task store (in-memory with TTL eviction) @@ -602,7 +598,7 @@ async function runAgent(eventType: string, data: any, message: string, options?: const useReadonly = options?.toolSet === 'readonly'; const activeTools = useReadonly ? readonlyTools : tools; - const activeAnthropicTools = useReadonly ? getReadonlyAnthropicTools() : getAnthropicTools(); + const activeLlmTools = useReadonly ? buildReadonlyLlmTools() : buildLlmTools(tools); try { const userMessage = [ @@ -624,21 +620,21 @@ async function runAgent(eventType: string, data: any, message: string, options?: ? `${memoryContext}\n\n---\n\n${userMessage}` : userMessage; - const messages: Anthropic.MessageParam[] = [{ role: 'user', content: fullUserMessage }]; + const messages: LlmMessage[] = [{ role: 'user', content: fullUserMessage }]; for (let turn = 0; turn < MAX_TURNS; turn++) { - const response = await anthropic.messages.create({ + const response = await llmClient.complete({ model: MODEL, max_tokens: 4096, system: systemPrompt, - tools: activeAnthropicTools, + tools: activeLlmTools, messages, }); // If done, return the text response if (response.stop_reason === 'end_turn') { const text = response.content - .filter((b): b is Anthropic.TextBlock => b.type === 'text') + .filter((b): b is LlmTextBlock => b.type === 'text') .map(b => b.text) .join('\n'); console.log(`[agent] Completed in ${turn + 1} turn(s): ${text.slice(0, 200)}`); @@ -646,65 +642,57 @@ async function runAgent(eventType: string, data: any, message: string, options?: } // Add assistant response to messages - messages.push({ role: 'assistant', content: response.content as any }); - - // Handle pause_turn (long-running server tool like web search) - // Just continue the loop — pass the response back to let Claude continue - if ((response.stop_reason as string) === 'pause_turn') { - console.log(`[agent] pause_turn — continuing`); - messages.push({ role: 'user', content: [{ type: 'text', text: 'Continue.' }] }); - continue; - } + messages.push({ role: 'assistant', content: response.content }); - // Execute local tool calls (skip server_tool_use — handled by API) - const toolResults: Anthropic.ToolResultBlockParam[] = []; + // Execute local tool calls + const toolResults: LlmToolResultBlock[] = []; for (const block of response.content) { - if (block.type === 'tool_use') { - const tool = activeTools.find(t => t.name === block.name); - if (!tool) { - toolResults.push({ - type: 'tool_result', - tool_use_id: block.id, - content: JSON.stringify({ error: `Unknown tool: ${block.name}` }), - }); - continue; - } + if (block.type !== 'tool_use') continue; + const toolUse = block as LlmToolUseBlock; + const tool = activeTools.find(t => t.name === toolUse.name); + if (!tool) { + toolResults.push({ + type: 'tool_result', + tool_use_id: toolUse.id, + content: JSON.stringify({ error: `Unknown tool: ${toolUse.name}` }), + }); + continue; + } - console.log(`[agent] Tool call: ${block.name}(${JSON.stringify(block.input).slice(0, 100)})`); + console.log(`[agent] Tool call: ${toolUse.name}(${JSON.stringify(toolUse.input).slice(0, 100)})`); - try { - const result = await tool.handler(block.input); - const resultStr = JSON.stringify(result); - // Log result for key tools (truncated for readability) - if (['generate_image', 'generate_video', 'store_deliverable'].includes(block.name)) { - console.log(`[agent] Tool result (${block.name}): ${resultStr.slice(0, 200)}`); - } - // Security scan tool output before feeding back to Claude - const outputScan = scanOutput(block.name, resultStr); - if (outputScan.action === 'block') { - console.warn(`[security] Blocked output from ${block.name}: ${outputScan.flagged.join(', ')}`); - toolResults.push({ - type: 'tool_result', - tool_use_id: block.id, - content: JSON.stringify({ error: 'Output blocked by security policy' }), - is_error: true, - }); - } else { - toolResults.push({ - type: 'tool_result', - tool_use_id: block.id, - content: outputScan.text, - }); - } - } catch (err: any) { - console.error(`[agent] Tool error (${block.name}):`, err.message); + try { + const result = await tool.handler(toolUse.input); + const resultStr = JSON.stringify(result); + // Log result for key tools (truncated for readability) + if (['generate_image', 'generate_video', 'store_deliverable'].includes(toolUse.name)) { + console.log(`[agent] Tool result (${toolUse.name}): ${resultStr.slice(0, 200)}`); + } + // Security scan tool output before feeding back to the LLM + const outputScan = scanOutput(toolUse.name, resultStr); + if (outputScan.action === 'block') { + console.warn(`[security] Blocked output from ${toolUse.name}: ${outputScan.flagged.join(', ')}`); toolResults.push({ type: 'tool_result', - tool_use_id: block.id, - content: JSON.stringify({ error: err.message }), + tool_use_id: toolUse.id, + content: JSON.stringify({ error: 'Output blocked by security policy' }), is_error: true, }); + } else { + toolResults.push({ + type: 'tool_result', + tool_use_id: toolUse.id, + content: outputScan.text, + }); } + } catch (err: any) { + console.error(`[agent] Tool error (${toolUse.name}):`, err.message); + toolResults.push({ + type: 'tool_result', + tool_use_id: toolUse.id, + content: JSON.stringify({ error: err.message }), + is_error: true, + }); } } @@ -1854,7 +1842,7 @@ const server = app.listen(port, () => { console.log(`[agent-runner] ${tools.length} tools loaded (A2A mode: ${a2aToolMode}, ${a2aToolMode === 'readonly' ? readonlyTools.length : tools.length} tools for A2A)`); console.log(`[agent-runner] Account: ${process.env.XPR_ACCOUNT}`); console.log(`[agent-runner] Mode: ${AGENT_MODE}`); - console.log(`[agent-runner] Model: ${MODEL}`); + console.log(`[agent-runner] LLM: ${llmClient.provider} (${MODEL})`); console.log(`[agent-runner] Network: ${process.env.XPR_NETWORK || 'mainnet'}`); console.log(`[agent-runner] A2A auth: ${a2aAuthConfig.authRequired ? 'required' : 'optional'}, rate limit: ${a2aAuthConfig.rateLimit}/min`); if (a2aAuthConfig.minTrustScore > 0) console.log(`[agent-runner] A2A min trust score: ${a2aAuthConfig.minTrustScore}`); diff --git a/openclaw/starter/agent/src/llm/anthropic.ts b/openclaw/starter/agent/src/llm/anthropic.ts new file mode 100644 index 0000000..7cce58e --- /dev/null +++ b/openclaw/starter/agent/src/llm/anthropic.ts @@ -0,0 +1,69 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { + LlmClient, + LlmClientOptions, + LlmCompletionRequest, + LlmCompletionResponse, + LlmContentBlock, + LlmStopReason, + DEFAULT_MODELS, +} from './types'; + +/** + * Anthropic Messages API client. Pass-through — our internal types already + * match Anthropic's shape, so translation is mostly a cast. + */ +export class AnthropicLlmClient implements LlmClient { + readonly provider = 'anthropic' as const; + readonly model: string; + private client: Anthropic; + + constructor(opts: LlmClientOptions) { + this.client = new Anthropic({ apiKey: opts.apiKey }); + this.model = opts.model || DEFAULT_MODELS.anthropic; + } + + async complete(req: LlmCompletionRequest): Promise { + const response = await this.client.messages.create({ + model: req.model || this.model, + max_tokens: req.max_tokens, + system: req.system, + tools: req.tools.map((t) => ({ + name: t.name, + description: t.description, + input_schema: t.input_schema as any, + })), + messages: req.messages as any, // shapes match + }); + + const content: LlmContentBlock[] = []; + for (const block of response.content) { + if (block.type === 'text') { + content.push({ type: 'text', text: block.text }); + } else if (block.type === 'tool_use') { + content.push({ + type: 'tool_use', + id: block.id, + name: block.name, + input: (block.input || {}) as Record, + }); + } + // Skip server_tool_use blocks — Anthropic-managed tools (web_search) handled internally + } + + // Map stop_reason → our union + let stop_reason: LlmStopReason = 'end_turn'; + if (response.stop_reason === 'tool_use') stop_reason = 'tool_use'; + else if (response.stop_reason === 'max_tokens') stop_reason = 'max_tokens'; + else if (response.stop_reason === 'stop_sequence') stop_reason = 'stop_sequence'; + + return { + content, + stop_reason, + usage: { + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + }, + }; + } +} diff --git a/openclaw/starter/agent/src/llm/factory.ts b/openclaw/starter/agent/src/llm/factory.ts new file mode 100644 index 0000000..27dc665 --- /dev/null +++ b/openclaw/starter/agent/src/llm/factory.ts @@ -0,0 +1,102 @@ +import { LlmClient, LlmProvider, detectProviderFromKey } from './types'; + +/** + * Build an LLM client from env. Resolves provider, model, and API key in + * this priority order: + * + * 1. Explicit args (passed by the runner's launch code) + * 2. AGENT_LLM_PROVIDER env var + * 3. Detect from API key prefix (sk-ant-… → anthropic, xai-… → xai, …) + * + * The API key is picked from the provider-specific env var + * (ANTHROPIC_API_KEY / OPENAI_API_KEY / XAI_API_KEY / GEMINI_API_KEY) if set, + * otherwise from the generic ANTHROPIC_API_KEY for back-compat with the + * pre-multi-provider config. + * + * Model picks from AGENT_MODEL (legacy / shared) first, then + * AGENT_MODEL_, then the provider's hardcoded default. + */ +export function createLlmClientFromEnv(opts?: { + provider?: LlmProvider; + apiKey?: string; + model?: string; +}): LlmClient { + let provider: LlmProvider | null = opts?.provider || null; + if (!provider) { + const envProvider = (process.env.AGENT_LLM_PROVIDER || '').toLowerCase(); + if (envProvider === 'anthropic' || envProvider === 'openai' || envProvider === 'xai' || envProvider === 'gemini') { + provider = envProvider; + } + } + + // Pull the per-provider key from env (preferred), then fall back to + // ANTHROPIC_API_KEY for back-compat with single-provider setups. + const providerKeyEnv: Record = { + anthropic: 'ANTHROPIC_API_KEY', + openai: 'OPENAI_API_KEY', + xai: 'XAI_API_KEY', + gemini: 'GEMINI_API_KEY', + }; + + let apiKey = opts?.apiKey || ''; + + // If we still don't have a provider, try to detect from any key we have. + if (!provider) { + if (apiKey) { + provider = detectProviderFromKey(apiKey); + } + if (!provider) { + // Last-resort: scan all provider-specific env vars and pick whichever is set. + for (const p of ['anthropic', 'openai', 'xai', 'gemini'] as LlmProvider[]) { + if (process.env[providerKeyEnv[p]]) { + provider = p; + break; + } + } + } + } + + if (!provider) provider = 'anthropic'; // ultimate fallback for back-compat + + // Pick the API key for the resolved provider. + if (!apiKey) { + apiKey = process.env[providerKeyEnv[provider]] || ''; + if (!apiKey && provider === 'anthropic') { + apiKey = process.env.ANTHROPIC_API_KEY || ''; + } + } + + if (!apiKey) { + throw new Error( + `[llm] No API key found for provider '${provider}'. Set ${providerKeyEnv[provider]} or pass --api-key on the CLI.`, + ); + } + + // Resolve model. AGENT_MODEL (legacy) wins; then AGENT_MODEL_; then default. + let model = opts?.model; + if (!model) { + model = process.env.AGENT_MODEL + || process.env[`AGENT_MODEL_${provider.toUpperCase()}`] + || undefined; + } + + // Lazy-load the impl so we don't pay the cost of importing all SDKs at boot. + switch (provider) { + case 'anthropic': { + const { AnthropicLlmClient } = require('./anthropic'); + return new AnthropicLlmClient({ apiKey, model }); + } + case 'openai': { + const { OpenAiLlmClient } = require('./openai'); + return new OpenAiLlmClient({ apiKey, model }); + } + case 'xai': { + const { XaiLlmClient } = require('./openai'); + return new XaiLlmClient({ apiKey, model }); + } + case 'gemini': { + const { GeminiLlmClient } = require('./gemini'); + return new GeminiLlmClient({ apiKey, model }); + } + } +} diff --git a/openclaw/starter/agent/src/llm/gemini.ts b/openclaw/starter/agent/src/llm/gemini.ts new file mode 100644 index 0000000..69d1642 --- /dev/null +++ b/openclaw/starter/agent/src/llm/gemini.ts @@ -0,0 +1,154 @@ +import { GoogleGenerativeAI, FunctionDeclarationsTool, Content, Part } from '@google/generative-ai'; +import { + LlmClient, + LlmClientOptions, + LlmCompletionRequest, + LlmCompletionResponse, + LlmContentBlock, + LlmStopReason, + DEFAULT_MODELS, +} from './types'; + +/** + * Google Gemini client. Uses @google/generative-ai with function calling. + * + * Gemini's quirks vs Anthropic/OpenAI: + * - system prompt goes in `systemInstruction` on the model, not in messages + * - assistant role is called 'model' + * - tool calls come back as `functionCall` parts; tool results go in as + * `functionResponse` parts on a 'user' (function) message + * - tool_use_id doesn't exist natively — Gemini matches by tool name, + * so we generate stable IDs from `${name}-${index}` for our internal use + */ +export class GeminiLlmClient implements LlmClient { + readonly provider = 'gemini' as const; + readonly model: string; + private genai: GoogleGenerativeAI; + + constructor(opts: LlmClientOptions) { + this.genai = new GoogleGenerativeAI(opts.apiKey); + this.model = opts.model || DEFAULT_MODELS.gemini; + } + + async complete(req: LlmCompletionRequest): Promise { + const model = this.genai.getGenerativeModel({ + model: req.model || this.model, + systemInstruction: req.system, + tools: req.tools.length > 0 + ? ([{ + functionDeclarations: req.tools.map((t) => ({ + name: t.name, + description: t.description, + parameters: t.input_schema as any, + })), + }] as FunctionDeclarationsTool[]) + : undefined, + generationConfig: { + maxOutputTokens: req.max_tokens, + }, + }); + + // Translate our messages → Gemini Content[] + // We track tool_use_id → tool name so we can re-pair function responses. + const toolUseIdToName: Record = {}; + const history: Content[] = []; + + for (const msg of req.messages) { + if (msg.role === 'user') { + if (typeof msg.content === 'string') { + history.push({ role: 'user', parts: [{ text: msg.content }] }); + } else { + // Mix of text + tool_result blocks + const textParts: Part[] = []; + const functionResponseParts: Part[] = []; + for (const block of msg.content) { + if (block.type === 'text') { + textParts.push({ text: block.text }); + } else if (block.type === 'tool_result') { + const fnName = toolUseIdToName[block.tool_use_id] || 'unknown'; + let responseObj: unknown; + try { + responseObj = JSON.parse(block.content); + } catch { + responseObj = { result: block.content }; + } + functionResponseParts.push({ + functionResponse: { + name: fnName, + response: responseObj as object, + }, + } as Part); + } + } + // Gemini wants function responses in their own 'function' or 'user' message + if (functionResponseParts.length > 0) { + history.push({ role: 'function', parts: functionResponseParts }); + } + if (textParts.length > 0) { + history.push({ role: 'user', parts: textParts }); + } + } + } else { + // Assistant — track tool_use ids so tool_result blocks can map back + const parts: Part[] = []; + for (const block of msg.content) { + if (block.type === 'text') { + parts.push({ text: block.text }); + } else if (block.type === 'tool_use') { + toolUseIdToName[block.id] = block.name; + parts.push({ + functionCall: { + name: block.name, + args: block.input as object, + }, + } as Part); + } + } + history.push({ role: 'model', parts }); + } + } + + // Send the conversation + const lastUserContent = history.pop(); + if (!lastUserContent) { + throw new Error('Gemini: messages array cannot be empty'); + } + const chat = model.startChat({ history }); + const result = await chat.sendMessage(lastUserContent.parts); + const response = result.response; + + const content: LlmContentBlock[] = []; + let toolUseIndex = 0; + const responseText = response.text(); + if (responseText) { + content.push({ type: 'text', text: responseText }); + } + + // Extract function calls from the response + const functionCalls = response.functionCalls?.() || []; + for (const call of functionCalls) { + content.push({ + type: 'tool_use', + id: `${call.name}-${toolUseIndex++}-${Date.now()}`, + name: call.name, + input: (call.args || {}) as Record, + }); + } + + const stop_reason: LlmStopReason = + functionCalls.length > 0 + ? 'tool_use' + : response.candidates?.[0]?.finishReason === 'MAX_TOKENS' + ? 'max_tokens' + : 'end_turn'; + + const usage = response.usageMetadata + ? { + input_tokens: response.usageMetadata.promptTokenCount, + output_tokens: response.usageMetadata.candidatesTokenCount, + } + : undefined; + + return { content, stop_reason, usage }; + } +} diff --git a/openclaw/starter/agent/src/llm/index.ts b/openclaw/starter/agent/src/llm/index.ts new file mode 100644 index 0000000..febf86a --- /dev/null +++ b/openclaw/starter/agent/src/llm/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export { createLlmClientFromEnv } from './factory'; diff --git a/openclaw/starter/agent/src/llm/openai.ts b/openclaw/starter/agent/src/llm/openai.ts new file mode 100644 index 0000000..9e1d178 --- /dev/null +++ b/openclaw/starter/agent/src/llm/openai.ts @@ -0,0 +1,176 @@ +import OpenAI from 'openai'; +import { + LlmClient, + LlmClientOptions, + LlmCompletionRequest, + LlmCompletionResponse, + LlmContentBlock, + LlmMessage, + LlmProvider, + LlmStopReason, + DEFAULT_MODELS, +} from './types'; + +interface OpenAiCompatibleOptions extends LlmClientOptions { + /** Used to point the OpenAI SDK at a different API root (e.g. xAI's https://api.x.ai/v1). */ + baseURL?: string; + /** Distinguish openai vs xai for `provider` reporting + default model selection. */ + flavor?: 'openai' | 'xai'; +} + +/** + * OpenAI Chat Completions client. Also serves xAI — same SDK, different + * baseURL (https://api.x.ai/v1). xAI is OpenAI-API-compatible including + * tool calling. + */ +export class OpenAiLlmClient implements LlmClient { + readonly provider: LlmProvider; + readonly model: string; + private client: OpenAI; + + constructor(opts: OpenAiCompatibleOptions) { + const flavor = opts.flavor || 'openai'; + this.provider = flavor; + this.client = new OpenAI({ + apiKey: opts.apiKey, + baseURL: opts.baseURL, // undefined → defaults to api.openai.com + }); + this.model = opts.model || DEFAULT_MODELS[flavor]; + } + + async complete(req: LlmCompletionRequest): Promise { + // Translate our internal message shape → OpenAI chat-completions shape. + // The big shape differences: + // - system message is a separate field in Anthropic; in OpenAI it's + // the first message with role='system'. + // - tool_use in assistant content → tool_calls array on assistant + // message, with content set to a string (often empty). + // - tool_result blocks in user content → separate messages with + // role='tool', tool_call_id, content as string. + const openaiMessages: OpenAI.ChatCompletionMessageParam[] = []; + openaiMessages.push({ role: 'system', content: req.system }); + + for (const msg of req.messages) { + if (msg.role === 'user') { + if (typeof msg.content === 'string') { + openaiMessages.push({ role: 'user', content: msg.content }); + } else { + // Mix of text + tool_result blocks. Tool results become standalone + // role=tool messages; remaining text becomes a single user message. + const textParts: string[] = []; + for (const block of msg.content) { + if (block.type === 'text') { + textParts.push(block.text); + } else if (block.type === 'tool_result') { + openaiMessages.push({ + role: 'tool', + tool_call_id: block.tool_use_id, + content: block.content, + }); + } + } + if (textParts.length > 0) { + openaiMessages.push({ role: 'user', content: textParts.join('\n') }); + } + } + } else { + // Assistant message — split into content text + tool_calls. + const textParts: string[] = []; + const tool_calls: OpenAI.ChatCompletionMessageToolCall[] = []; + for (const block of msg.content) { + if (block.type === 'text') { + textParts.push(block.text); + } else if (block.type === 'tool_use') { + tool_calls.push({ + id: block.id, + type: 'function', + function: { + name: block.name, + arguments: JSON.stringify(block.input || {}), + }, + }); + } + } + openaiMessages.push({ + role: 'assistant', + content: textParts.length > 0 ? textParts.join('\n') : null, + ...(tool_calls.length > 0 ? { tool_calls } : {}), + } as OpenAI.ChatCompletionAssistantMessageParam); + } + } + + const response = await this.client.chat.completions.create({ + model: req.model || this.model, + max_tokens: req.max_tokens, + messages: openaiMessages, + tools: req.tools.length > 0 + ? req.tools.map((t) => ({ + type: 'function' as const, + function: { + name: t.name, + description: t.description, + parameters: t.input_schema as Record, + }, + })) + : undefined, + }); + + const choice = response.choices[0]; + const msg = choice.message; + + const content: LlmContentBlock[] = []; + if (msg.content) { + content.push({ type: 'text', text: msg.content }); + } + if (msg.tool_calls) { + for (const call of msg.tool_calls) { + if (call.type !== 'function') continue; + let input: Record = {}; + try { + input = call.function.arguments ? JSON.parse(call.function.arguments) : {}; + } catch { + // If the model returned malformed JSON, pass it through as a raw string + // and let the tool handler reject — better than silently dropping the call. + input = { __raw: call.function.arguments }; + } + content.push({ + type: 'tool_use', + id: call.id, + name: call.function.name, + input, + }); + } + } + + let stop_reason: LlmStopReason = 'end_turn'; + if (choice.finish_reason === 'tool_calls') stop_reason = 'tool_use'; + else if (choice.finish_reason === 'length') stop_reason = 'max_tokens'; + else if (choice.finish_reason === 'stop') stop_reason = 'end_turn'; + + return { + content, + stop_reason, + usage: response.usage + ? { + input_tokens: response.usage.prompt_tokens, + output_tokens: response.usage.completion_tokens, + } + : undefined, + }; + } +} + +/** + * xAI client — same as OpenAI client with baseURL pointing at api.x.ai/v1. + * xAI is OpenAI-compatible at the API level including tool calling. + */ +export class XaiLlmClient extends OpenAiLlmClient { + constructor(opts: LlmClientOptions) { + super({ + apiKey: opts.apiKey, + model: opts.model, + baseURL: 'https://api.x.ai/v1', + flavor: 'xai', + }); + } +} diff --git a/openclaw/starter/agent/src/llm/types.ts b/openclaw/starter/agent/src/llm/types.ts new file mode 100644 index 0000000..1015ed9 --- /dev/null +++ b/openclaw/starter/agent/src/llm/types.ts @@ -0,0 +1,117 @@ +/** + * Unified LLM client interface. Each provider (Anthropic / OpenAI / xAI / + * Gemini) implements this. The agent runner only knows about these types — + * never imports any provider SDK directly outside of `llm/.ts`. + * + * The internal message/tool shapes mirror Anthropic's Messages API because + * that's what the runner originally targeted; OpenAI / xAI / Gemini impls + * translate to their own formats inside complete() and back. + */ + +export type LlmProvider = 'anthropic' | 'openai' | 'xai' | 'gemini'; + +export interface LlmTool { + /** Tool name as the LLM will see it (e.g. `xpr_get_agent`). */ + name: string; + description: string; + /** JSON schema for the parameters object. */ + input_schema: { + type: 'object'; + properties?: Record; + required?: string[]; + [k: string]: unknown; + }; +} + +export interface LlmTextBlock { + type: 'text'; + text: string; +} + +export interface LlmToolUseBlock { + type: 'tool_use'; + /** Stable identifier the assistant uses to refer to this tool call when its result comes back. */ + id: string; + name: string; + input: Record; +} + +export type LlmContentBlock = LlmTextBlock | LlmToolUseBlock; + +export interface LlmToolResultBlock { + type: 'tool_result'; + tool_use_id: string; + /** String form of the tool's return value (JSON serialised). */ + content: string; + is_error?: boolean; +} + +export interface LlmUserMessage { + role: 'user'; + /** Plain text OR a mix of text + tool_result blocks (when responding to tool calls). */ + content: string | Array; +} + +export interface LlmAssistantMessage { + role: 'assistant'; + content: LlmContentBlock[]; +} + +export type LlmMessage = LlmUserMessage | LlmAssistantMessage; + +export type LlmStopReason = + | 'end_turn' // assistant finished talking + | 'tool_use' // assistant wants tools called + | 'max_tokens' // truncated by max_tokens + | 'stop_sequence'; + +export interface LlmCompletionRequest { + system: string; + messages: LlmMessage[]; + tools: LlmTool[]; + max_tokens: number; + /** Optional override; impl falls back to its `defaultModel`. */ + model?: string; +} + +export interface LlmCompletionResponse { + content: LlmContentBlock[]; + stop_reason: LlmStopReason; + usage?: { + input_tokens: number; + output_tokens: number; + }; +} + +export interface LlmClient { + provider: LlmProvider; + /** Resolved model name actually used for requests (after defaults applied). */ + model: string; + complete(req: LlmCompletionRequest): Promise; +} + +export interface LlmClientOptions { + apiKey: string; + /** Override the default model for this provider. */ + model?: string; +} + +/** Default model per provider. Override via AGENT_MODEL or AGENT_MODEL_. */ +export const DEFAULT_MODELS: Record = { + anthropic: 'claude-sonnet-4-6', + openai: 'gpt-5', + xai: 'grok-3-latest', + gemini: 'gemini-2.5-flash', +}; + +/** + * Auto-detect provider from an API key prefix. Used when --provider is omitted. + * Returns null if the prefix is ambiguous — caller should require --provider explicitly. + */ +export function detectProviderFromKey(key: string): LlmProvider | null { + if (key.startsWith('sk-ant-')) return 'anthropic'; + if (key.startsWith('xai-')) return 'xai'; + if (key.startsWith('sk-proj-') || key.startsWith('sk-svcacct-') || key.startsWith('sk-')) return 'openai'; + if (key.startsWith('AI') && key.length > 30) return 'gemini'; // Gemini keys start with "AI" + ~37 chars + return null; +} diff --git a/openclaw/starter/start.sh b/openclaw/starter/start.sh index 7d9ae7b..a636194 100755 --- a/openclaw/starter/start.sh +++ b/openclaw/starter/start.sh @@ -50,10 +50,21 @@ log "Node.js $(node -v)" # runner's own env-var defaults — drift causes operator surprise # (e.g. .env says 1000 XPR cap but agent applies 100 XPR). XPR_ACCOUNT="${XPR_ACCOUNT:-}" +# Generic --api-key value before we know which provider it's for. Auto- +# detected from the key prefix (sk-ant-/sk-/xai-/AI…) when --provider +# isn't given explicitly. +API_KEY="" +AGENT_LLM_PROVIDER="${AGENT_LLM_PROVIDER:-}" ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" +OPENAI_API_KEY="${OPENAI_API_KEY:-}" +XAI_API_KEY="${XAI_API_KEY:-}" +GEMINI_API_KEY="${GEMINI_API_KEY:-}" XPR_NETWORK="${XPR_NETWORK:-mainnet}" XPR_RPC_ENDPOINT="${XPR_RPC_ENDPOINT:-}" -AGENT_MODEL="${AGENT_MODEL:-claude-sonnet-4-6}" +# AGENT_MODEL stays empty by default — the agent runner picks the right +# default per provider (claude-sonnet-4-6, gpt-5, grok-3-latest, +# gemini-2.5-flash). Override here only if you want a specific model. +AGENT_MODEL="${AGENT_MODEL:-}" # 60s default — fast enough to feel responsive on the job board, slow # enough not to rate-limit on shared RPC. Tune via --poll-interval or # POLL_INTERVAL in .env (the agent runner itself accepts down to 5s). @@ -72,7 +83,8 @@ while [[ $# -gt 0 ]]; do echo " proton key:add" exit 1 ;; - --api-key) ANTHROPIC_API_KEY="$2"; shift 2 ;; + --api-key) API_KEY="$2"; shift 2 ;; + --provider) AGENT_LLM_PROVIDER="$2"; shift 2 ;; --network) XPR_NETWORK="$2"; shift 2 ;; --rpc) XPR_RPC_ENDPOINT="$2"; shift 2 ;; --model) AGENT_MODEL="$2"; shift 2 ;; @@ -81,6 +93,39 @@ while [[ $# -gt 0 ]]; do esac done +# ── Resolve LLM provider + API key ────────────── +# Order: explicit --provider wins. Otherwise auto-detect from --api-key +# prefix. Otherwise scan per-provider env vars. Default 'anthropic' if +# we still don't know. +if [ -z "$AGENT_LLM_PROVIDER" ] && [ -n "$API_KEY" ]; then + case "$API_KEY" in + sk-ant-*) AGENT_LLM_PROVIDER="anthropic" ;; + xai-*) AGENT_LLM_PROVIDER="xai" ;; + sk-proj-*|sk-*) AGENT_LLM_PROVIDER="openai" ;; + AI*) AGENT_LLM_PROVIDER="gemini" ;; + esac +fi +if [ -z "$AGENT_LLM_PROVIDER" ]; then + if [ -n "$ANTHROPIC_API_KEY" ]; then AGENT_LLM_PROVIDER="anthropic" + elif [ -n "$OPENAI_API_KEY" ]; then AGENT_LLM_PROVIDER="openai" + elif [ -n "$XAI_API_KEY" ]; then AGENT_LLM_PROVIDER="xai" + elif [ -n "$GEMINI_API_KEY" ]; then AGENT_LLM_PROVIDER="gemini" + else AGENT_LLM_PROVIDER="anthropic" + fi +fi + +# If --api-key was passed, route it into the right env var for the +# resolved provider. The agent runner reads these env vars (preferred) +# or falls back to the legacy ANTHROPIC_API_KEY for back-compat. +if [ -n "$API_KEY" ]; then + case "$AGENT_LLM_PROVIDER" in + anthropic) ANTHROPIC_API_KEY="$API_KEY" ;; + openai) OPENAI_API_KEY="$API_KEY" ;; + xai) XAI_API_KEY="$API_KEY" ;; + gemini) GEMINI_API_KEY="$API_KEY" ;; + esac +fi + # ── Refuse legacy XPR_PRIVATE_KEY env var ────── if [ -n "${XPR_PRIVATE_KEY:-}" ]; then err "XPR_PRIVATE_KEY is set in environment but is no longer supported." @@ -114,6 +159,18 @@ if [ -z "$XPR_RPC_ENDPOINT" ]; then fi fi +# ── Resolve which key to actually use for the chosen provider ── +case "$AGENT_LLM_PROVIDER" in + anthropic) RESOLVED_API_KEY="$ANTHROPIC_API_KEY"; KEY_NAME="ANTHROPIC_API_KEY"; KEY_HINT="sk-ant-..." ;; + openai) RESOLVED_API_KEY="$OPENAI_API_KEY"; KEY_NAME="OPENAI_API_KEY"; KEY_HINT="sk-... or sk-proj-..." ;; + xai) RESOLVED_API_KEY="$XAI_API_KEY"; KEY_NAME="XAI_API_KEY"; KEY_HINT="xai-..." ;; + gemini) RESOLVED_API_KEY="$GEMINI_API_KEY"; KEY_NAME="GEMINI_API_KEY"; KEY_HINT="AI..." ;; + *) + err "Unknown LLM provider: '$AGENT_LLM_PROVIDER'. Supported: anthropic, openai, xai, gemini." + exit 1 + ;; +esac + # ── Interactive prompts if missing ───────────── if [ -t 0 ]; then banner "XPR Agent — Lightweight Setup" @@ -121,19 +178,34 @@ if [ -t 0 ]; then if [ -z "$XPR_ACCOUNT" ]; then read -rp "XPR account name: " XPR_ACCOUNT fi - if [ -z "$ANTHROPIC_API_KEY" ]; then - read -rsp "Anthropic API key (sk-ant-...): " ANTHROPIC_API_KEY + if [ -z "$RESOLVED_API_KEY" ]; then + read -rsp "LLM API key for ${AGENT_LLM_PROVIDER} (${KEY_HINT}): " RESOLVED_API_KEY echo + # Stash it back into the correct env-var slot for downstream code + case "$AGENT_LLM_PROVIDER" in + anthropic) ANTHROPIC_API_KEY="$RESOLVED_API_KEY" ;; + openai) OPENAI_API_KEY="$RESOLVED_API_KEY" ;; + xai) XAI_API_KEY="$RESOLVED_API_KEY" ;; + gemini) GEMINI_API_KEY="$RESOLVED_API_KEY" ;; + esac fi fi # ── Validate ─────────────────────────────────── -if [ -z "$XPR_ACCOUNT" ] || [ -z "$ANTHROPIC_API_KEY" ]; then +if [ -z "$XPR_ACCOUNT" ] || [ -z "$RESOLVED_API_KEY" ]; then err "Missing required config. Provide via CLI args, .env file, or environment variables." echo "" echo " Required:" echo " --account XPR account name" - echo " --api-key Anthropic API key" + echo " --api-key LLM API key for the chosen provider" + echo "" + echo " LLM provider auto-detected from --api-key prefix when omitted:" + echo " sk-ant-... → anthropic (default model: claude-sonnet-4-6)" + echo " sk-... / sk-proj → openai (default model: gpt-5)" + echo " xai-... → xai (default model: grok-3-latest)" + echo " AI... → gemini (default model: gemini-2.5-flash)" + echo "" + echo " Or set explicitly: --provider " echo "" echo " Signing key (no flag — handled by proton CLI):" echo " proton key:add # one-time setup" @@ -191,7 +263,7 @@ fi log "Account: ${XPR_ACCOUNT}" log "Network: ${XPR_NETWORK} (${XPR_RPC_ENDPOINT})" -log "Model: ${AGENT_MODEL}" +log "LLM: ${AGENT_LLM_PROVIDER}${AGENT_MODEL:+ (${AGENT_MODEL})}" log "Poll interval: ${POLL_INTERVAL}s" # ── Pillar 2 diagnostic (recommend owner-permission lockdown) ──── @@ -288,7 +360,17 @@ XPR_PERMISSION=active XPR_NETWORK=${XPR_NETWORK} XPR_RPC_ENDPOINT=${XPR_RPC_ENDPOINT} INDEXER_URL=${INDEXER_URL:-https://indexer.xpragents.com} +# LLM provider — anthropic | openai | xai | gemini. Set only ONE key. +AGENT_LLM_PROVIDER=${AGENT_LLM_PROVIDER} ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} +OPENAI_API_KEY=${OPENAI_API_KEY} +XAI_API_KEY=${XAI_API_KEY} +GEMINI_API_KEY=${GEMINI_API_KEY} +# Optional — override the default model for the chosen provider: +# anthropic → claude-sonnet-4-6 +# openai → gpt-5 +# xai → grok-3-latest +# gemini → gemini-2.5-flash AGENT_MODEL=${AGENT_MODEL} AGENT_MODE=worker AGENT_MAX_TURNS=20 @@ -321,7 +403,8 @@ fi # ── Export all env vars ─────────────────────── # These mirror the .env defaults — keep in sync. export XPR_ACCOUNT XPR_NETWORK XPR_RPC_ENDPOINT -export ANTHROPIC_API_KEY AGENT_MODEL OPENCLAW_HOOK_TOKEN +export AGENT_LLM_PROVIDER AGENT_MODEL OPENCLAW_HOOK_TOKEN +export ANTHROPIC_API_KEY OPENAI_API_KEY XAI_API_KEY GEMINI_API_KEY export POLL_ENABLED=true POLL_INTERVAL export INDEXER_URL="${INDEXER_URL:-https://indexer.xpragents.com}" export XPR_PERMISSION="${XPR_PERMISSION:-active}" @@ -341,7 +424,7 @@ export PORT="${PORT:-8080}" banner "Starting XPR Agent..." echo -e " Account: ${BOLD}${XPR_ACCOUNT}${NC}" echo -e " Network: ${XPR_NETWORK}" -echo -e " Model: ${AGENT_MODEL}" +echo -e " LLM: ${AGENT_LLM_PROVIDER}${AGENT_MODEL:+ (${AGENT_MODEL})}" echo -e " Poller: every ${POLL_INTERVAL}s" echo -e " Telegram: ${TELEGRAM_BOT_TOKEN:+enabled}${TELEGRAM_BOT_TOKEN:-disabled}" echo -e " Port: ${PORT}"