diff --git a/.env.example b/.env.example
index 66539b634..06d43070c 100644
--- a/.env.example
+++ b/.env.example
@@ -5,6 +5,7 @@
# ANTHROPIC_API_KEY=sk-ant-xxx
# OPENAI_API_KEY=sk-xxx
# GEMINI_API_KEY=xxx
+# CEREBRAS_API_KEY=xxx
# ── Chat Channel ──────────────────────────
# TELEGRAM_BOT_TOKEN=123456:ABC...
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 000000000..4be385b22
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,28 @@
+---
+name: Bug report
+about: Report a bug or unexpected behavior
+title: "[BUG]"
+labels: bug
+assignees: ''
+
+---
+
+## Quick Summary
+
+## Environment & Tools
+- **PicoClaw Version:** (e.g., v0.1.2 or commit hash)
+- **Go Version:** (e.g., go 1.22)
+- **AI Model & Provider:** (e.g., GPT-4o via OpenAI / DeepSeek via SiliconFlow)
+- **Operating System:** (e.g., Ubuntu 22.04 / macOS / Android Termux)
+- **Channels:** (e.g., Discord, Telegram, Feishu, ...)
+
+## 📸 Steps to Reproduce
+1.
+2.
+3.
+
+## ❌ Actual Behavior
+
+## ✅ Expected Behavior
+
+## 💬 Additional Context
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..d3df0e79c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,23 @@
+---
+name: Feature request
+about: Suggest a new idea or improvement
+title: "[Feature]"
+labels: enhancement
+assignees: ''
+
+---
+
+## 🎯 The Goal / Use Case
+
+## 💡 Proposed Solution
+
+## 🛠 Potential Implementation (Optional)
+
+## 🚦 Impact & Roadmap Alignment
+- [ ] This is a Core Feature
+- [ ] This is a Nice-to-Have / Enhancement
+- [ ] This aligns with the current Roadmap
+
+## 🔄 Alternatives Considered
+
+## 💬 Additional Context
diff --git a/.github/ISSUE_TEMPLATE/general-task---todo.md b/.github/ISSUE_TEMPLATE/general-task---todo.md
new file mode 100644
index 000000000..eab70c030
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/general-task---todo.md
@@ -0,0 +1,26 @@
+---
+name: General Task / Todo
+about: A specific piece of work like doc, refactoring, or maintenance.
+title: "[Task]"
+labels: ''
+assignees: ''
+
+---
+
+## 📝 Objective
+
+## 📋 To-Do List
+- [ ] Step 1
+- [ ] Step 2
+- [ ] Step 3
+
+## 🎯 Definition of Done (Acceptance Criteria)
+- [ ] Documentation is updated in the README/docs folder.
+- [ ] Code follows project linting standards.
+- [ ] (If applicable) Basic tests pass.
+
+## 💡 Context / Motivation
+
+## 🔗 Related Issues / PRs
+- Fixes #
+- Relates to #
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..c96b7da12
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,43 @@
+## 📝 Description
+
+
+
+## 🗣️ Type of Change
+- [ ] 🐞 Bug fix (non-breaking change which fixes an issue)
+- [ ] ✨ New feature (non-breaking change which adds functionality)
+- [ ] 📖 Documentation update
+- [ ] ⚡ Code refactoring (no functional changes, no api changes)
+
+## 🤖 AI Code Generation
+- [ ] 🤖 Fully AI-generated (100% AI, 0% Human)
+- [ ] 🛠️ Mostly AI-generated (AI draft, Human verified/modified)
+- [ ] 👨💻 Mostly Human-written (Human lead, AI assisted or none)
+
+
+## 🔗 Related Issue
+
+
+
+## 📚 Technical Context (Skip for Docs)
+- **Reference URL:**
+- **Reasoning:**
+
+## 🧪 Test Environment
+- **Hardware:**
+- **OS:**
+- **Model/Provider:**
+- **Channels:**
+
+
+## 📸 Evidence (Optional)
+
+Click to view Logs/Screenshots
+
+
+
+
+
+## ☑️ Checklist
+- [ ] My code/docs follow the style of this project.
+- [ ] I have performed a self-review of my own changes.
+- [ ] I have updated the documentation accordingly.
\ No newline at end of file
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 0f075b0bb..499613625 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -9,10 +9,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Setup Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version-file: go.mod
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 90ff635da..dadbed212 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -1,12 +1,18 @@
name: 🐳 Build & Push Docker Image
on:
- release:
- types: [published]
+ workflow_call:
+ inputs:
+ tag:
+ description: "Release tag"
+ required: true
+ type: string
env:
- REGISTRY: ghcr.io
- IMAGE_NAME: ${{ github.repository_owner }}/picoclaw
+ GHCR_REGISTRY: ghcr.io
+ GHCR_IMAGE_NAME: ${{ github.repository_owner }}/picoclaw
+ DOCKERHUB_REGISTRY: docker.io
+ DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
jobs:
build:
@@ -19,7 +25,9 @@ jobs:
steps:
# ── Checkout ──────────────────────────────
- name: 📥 Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.tag }}
# ── Docker Buildx ─────────────────────────
- name: 🔧 Set up Docker Buildx
@@ -27,36 +35,42 @@ jobs:
# ── Login to GHCR ─────────────────────────
- name: 🔑 Login to GitHub Container Registry
- if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
- registry: ${{ env.REGISTRY }}
+ registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- # ── Metadata (tags & labels) ──────────────
- - name: 🏷️ Extract Docker metadata
- id: meta
- uses: docker/metadata-action@v5
+ # ── Login to Docker Hub ────────────────────
+ - name: 🔑 Login to Docker Hub
+ uses: docker/login-action@v3
with:
- images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- tags: |
- type=ref,event=branch
- type=ref,event=pr
- type=semver,pattern={{version}}
- type=semver,pattern={{major}}.{{minor}}
- type=sha,prefix=
- type=raw,value=latest,enable={{is_default_branch}}
- type=raw,value={{date 'YYYYMMDD-HHmmss'}},enable={{is_default_branch}}
+ registry: ${{ env.DOCKERHUB_REGISTRY }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # ── Metadata (tags & labels) ──────────────
+ - name: 🏷️ Prepare image tags
+ id: tags
+ shell: bash
+ run: |
+ tag="${{ inputs.tag }}"
+ echo "ghcr_tag=${{ env.GHCR_REGISTRY }}/${{ env.GHCR_IMAGE_NAME }}:${tag}" >> "$GITHUB_OUTPUT"
+ echo "ghcr_latest=${{ env.GHCR_REGISTRY }}/${{ env.GHCR_IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT"
+ echo "dockerhub_tag=${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}:${tag}" >> "$GITHUB_OUTPUT"
+ echo "dockerhub_latest=${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT"
# ── Build & Push ──────────────────────────
- name: 🚀 Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
- push: ${{ github.event_name != 'pull_request' }}
- tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
+ push: true
+ tags: |
+ ${{ steps.tags.outputs.ghcr_tag }}
+ ${{ steps.tags.outputs.ghcr_latest }}
+ ${{ steps.tags.outputs.dockerhub_tag }}
+ ${{ steps.tags.outputs.dockerhub_latest }}
cache-from: type=gha
cache-to: type=gha,mode=max
- platforms: linux/amd64,linux/arm64
+ platforms: linux/amd64,linux/arm64,linux/riscv64
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index fac7597ea..55bf77e00 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -1,17 +1,39 @@
-name: pr-check
+name: PR
on:
- pull_request:
+ pull_request: { }
jobs:
- fmt-check:
+ lint:
+ name: Linter
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Setup Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+
+ - name: Run go generate
+ run: go generate ./...
+
+ - name: Golangci Lint
+ uses: golangci/golangci-lint-action@v9
+ with:
+ version: v2.10.1
+
+ # TODO: Remove once linter is properly configured
+ fmt-check:
+ name: Formatting
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Setup Go
+ uses: actions/setup-go@v6
with:
go-version-file: go.mod
@@ -20,15 +42,17 @@ jobs:
make fmt
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
+ # TODO: Remove once linter is properly configured
vet:
+ name: Vet
runs-on: ubuntu-latest
needs: fmt-check
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Setup Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version-file: go.mod
@@ -39,14 +63,15 @@ jobs:
run: go vet ./...
test:
+ name: Tests
runs-on: ubuntu-latest
needs: fmt-check
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
- name: Setup Go
- uses: actions/setup-go@v5
+ uses: actions/setup-go@v6
with:
go-version-file: go.mod
@@ -55,4 +80,3 @@ jobs:
- name: Run go test
run: go test ./...
-
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 59cc6caeb..786c893ef 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -26,74 +26,77 @@ jobs:
contents: write
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Create and push tag
shell: bash
+ env:
+ RELEASE_TAG: ${{ inputs.tag }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- git tag -a "${{ inputs.tag }}" -m "Release ${{ inputs.tag }}"
- git push origin "${{ inputs.tag }}"
+ git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG"
+ git push origin "$RELEASE_TAG"
- build-binaries:
- name: Build Release Binaries
+ release:
+ name: GoReleaser Release
needs: create-tag
runs-on: ubuntu-latest
- steps:
- - name: Checkout tag
- uses: actions/checkout@v4
- with:
- ref: ${{ inputs.tag }}
-
- - name: Setup Go from go.mod
- uses: actions/setup-go@v5
- with:
- go-version-file: go.mod
-
- - name: Build all binaries
- run: make build-all
-
- - name: Generate checksums
- shell: bash
- run: |
- shasum -a 256 build/picoclaw-* > build/sha256sums.txt
-
- - name: Upload release binaries artifact
- uses: actions/upload-artifact@v4
- with:
- name: picoclaw-binaries
- path: |
- build/picoclaw-*
- build/sha256sums.txt
- if-no-files-found: error
-
- create-release:
- name: Create GitHub Release
- needs: [create-tag, build-binaries]
- runs-on: ubuntu-latest
permissions:
contents: write
+ packages: write
steps:
- - name: Download all artifacts
- uses: actions/download-artifact@v4
+ - name: Checkout tag
+ uses: actions/checkout@v6
with:
- path: release-artifacts
+ fetch-depth: 0
+ ref: ${{ inputs.tag }}
- - name: Show downloaded files
- run: ls -R release-artifacts
-
- - name: Create release
- uses: softprops/action-gh-release@v2
+ - name: Setup Go from go.mod
+ id: setup-go
+ uses: actions/setup-go@v6
with:
- tag_name: ${{ inputs.tag }}
- name: ${{ inputs.tag }}
- draft: ${{ inputs.draft }}
- prerelease: ${{ inputs.prerelease }}
- files: |
- release-artifacts/**/*
- generate_release_notes: true
+ go-version-file: go.mod
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ registry: docker.io
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Run GoReleaser
+ uses: goreleaser/goreleaser-action@v6
+ with:
+ distribution: goreleaser
+ version: ~> v2
+ args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
+ DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
+ GOVERSION: ${{ steps.setup-go.outputs.go-version }}
+
+ - name: Apply release flags
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release edit "${{ inputs.tag }}" \
+ --draft=${{ inputs.draft }} \
+ --prerelease=${{ inputs.prerelease }}
diff --git a/.golangci.yaml b/.golangci.yaml
new file mode 100644
index 000000000..80e54ac1c
--- /dev/null
+++ b/.golangci.yaml
@@ -0,0 +1,184 @@
+version: "2"
+
+linters:
+ default: all
+ disable:
+ # TODO: Tweak for current project needs
+ - containedctx
+ - cyclop
+ - depguard
+ - dupl
+ - dupword
+ - err113
+ - exhaustruct
+ - funcorder
+ - gochecknoglobals
+ - godot
+ - intrange
+ - ireturn
+ - nlreturn
+ - noctx
+ - noinlineerr
+ - nonamedreturns
+ - tagliatelle
+ - testpackage
+ - varnamelen
+ - wrapcheck
+ - wsl
+ - wsl_v5
+
+ # TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
+ - bodyclose
+ - contextcheck
+ - dogsled
+ - embeddedstructfieldcheck
+ - errcheck
+ - errchkjson
+ - errorlint
+ - exhaustive
+ - forbidigo
+ - forcetypeassert
+ - funlen
+ - gochecknoinits
+ - gocognit
+ - goconst
+ - gocritic
+ - gocyclo
+ - godox
+ - goprintffuncname
+ - gosec
+ - govet
+ - ineffassign
+ - lll
+ - maintidx
+ - misspell
+ - mnd
+ - modernize
+ - nakedret
+ - nestif
+ - nilnil
+ - paralleltest
+ - perfsprint
+ - prealloc
+ - predeclared
+ - revive
+ - staticcheck
+ - tagalign
+ - testifylint
+ - thelper
+ - unparam
+ - unused
+ - usestdlibvars
+ - usetesting
+ - wastedassign
+ - whitespace
+ settings:
+ errcheck:
+ check-type-assertions: true
+ check-blank: true
+ exhaustive:
+ default-signifies-exhaustive: true
+ funlen:
+ lines: 120
+ statements: 40
+ gocognit:
+ min-complexity: 25
+ gocyclo:
+ min-complexity: 20
+ govet:
+ enable-all: true
+ disable:
+ - fieldalignment
+ lll:
+ line-length: 120
+ tab-width: 4
+ misspell:
+ locale: US
+ mnd:
+ checks:
+ - argument
+ - assign
+ - case
+ - condition
+ - operation
+ - return
+ nakedret:
+ max-func-lines: 3
+ revive:
+ enable-all-rules: true
+ rules:
+ - name: add-constant
+ disabled: true
+ - name: argument-limit
+ arguments:
+ - 7
+ severity: warning
+ - name: banned-characters
+ disabled: true
+ - name: cognitive-complexity
+ disabled: true
+ - name: comment-spacings
+ arguments:
+ - nolint
+ severity: warning
+ - name: cyclomatic
+ disabled: true
+ - name: file-header
+ disabled: true
+ - name: function-result-limit
+ arguments:
+ - 3
+ severity: warning
+ - name: function-length
+ disabled: true
+ - name: line-length-limit
+ disabled: true
+ - name: max-public-structs
+ disabled: true
+ - name: modifies-value-receiver
+ disabled: true
+ - name: package-comments
+ disabled: true
+ - name: unused-receiver
+ disabled: true
+ exclusions:
+ generated: lax
+ rules:
+ - linters:
+ - lll
+ source: '^//go:generate '
+ - linters:
+ - funlen
+ - maintidx
+ - gocognit
+ - gocyclo
+ path: _test\.go$
+
+issues:
+ max-issues-per-linter: 0
+ max-same-issues: 0
+
+formatters:
+ enable:
+ - goimports
+ # TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
+ # - gci
+ # - gofmt
+ # - gofumpt
+ # - golines
+ settings:
+ gci:
+ sections:
+ - standard
+ - default
+ - localmodule
+ custom-order: true
+ gofmt:
+ simplify: true
+ rewrite-rules:
+ - pattern: "interface{}"
+ replacement: "any"
+ - pattern: "a[b:len(a)]"
+ replacement: "a[b:]"
+ golines:
+ max-len: 120
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index a2c158331..2c47f7d86 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -5,10 +5,20 @@ version: 2
before:
hooks:
- go mod tidy
+ - go generate ./cmd/picoclaw
builds:
- - env:
+ - id: picoclaw
+ env:
- CGO_ENABLED=0
+ tags:
+ - stdjson
+ ldflags:
+ - -s -w
+ - -X main.version={{ .Version }}
+ - -X main.gitCommit={{ .ShortCommit }}
+ - -X main.buildTime={{ .Date }}
+ - -X main.goVersion={{ .Env.GOVERSION }}
goos:
- linux
- windows
@@ -26,6 +36,22 @@ builds:
- goos: windows
goarch: arm
+dockers_v2:
+ - id: picoclaw
+ dockerfile: Dockerfile.goreleaser
+ ids:
+ - picoclaw
+ images:
+ - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
+ - "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}"
+ tags:
+ - "{{ .Tag }}"
+ - "latest"
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/riscv64
+
archives:
- formats: [tar.gz]
# this name template makes the OS and Arch compatible with the results of `uname`.
@@ -48,10 +74,10 @@ changelog:
- "^docs:"
- "^test:"
-upx:
- - enabled: true
- compress: best
- lzma: true
+# upx:
+# - enabled: true
+# compress: best
+# lzma: true
release:
footer: >-
diff --git a/Dockerfile b/Dockerfile
index 433d962f2..0360cfda6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -22,10 +22,21 @@ FROM alpine:3.23
RUN apk add --no-cache ca-certificates tzdata curl
+# Health check
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD wget -q --spider http://localhost:18790/health || exit 1
+
# Copy binary
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
-# Create picoclaw home directory
+# Create non-root user and group
+RUN addgroup -g 1000 picoclaw && \
+ adduser -D -u 1000 -G picoclaw picoclaw
+
+# Switch to non-root user
+USER picoclaw
+
+# Run onboard to create initial directories and config
RUN /usr/local/bin/picoclaw onboard
ENTRYPOINT ["picoclaw"]
diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser
new file mode 100644
index 000000000..0cdc8c6bd
--- /dev/null
+++ b/Dockerfile.goreleaser
@@ -0,0 +1,10 @@
+FROM alpine:3.21
+
+ARG TARGETPLATFORM
+
+RUN apk add --no-cache ca-certificates tzdata
+
+COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
+
+ENTRYPOINT ["picoclaw"]
+CMD ["gateway"]
diff --git a/Makefile b/Makefile
index 058fdb790..ff280e3e4 100644
--- a/Makefile
+++ b/Makefile
@@ -11,11 +11,11 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date +%FT%T%z)
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
-LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION)"
+LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w"
# Go variables
GO?=go
-GOFLAGS?=-v
+GOFLAGS?=-v -tags stdjson
# Installation
INSTALL_PREFIX?=$(HOME)/.local
@@ -39,6 +39,8 @@ ifeq ($(UNAME_S),Linux)
ARCH=amd64
else ifeq ($(UNAME_M),aarch64)
ARCH=arm64
+ else ifeq ($(UNAME_M),loongarch64)
+ ARCH=loong64
else ifeq ($(UNAME_M),riscv64)
ARCH=riscv64
else
@@ -84,6 +86,7 @@ build-all: generate
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
+ GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
@@ -119,7 +122,7 @@ clean:
@rm -rf $(BUILD_DIR)
@echo "Clean complete"
-## fmt: Format Go code
+## vet: Run go vet for static analysis
vet:
@$(GO) vet ./...
@@ -131,11 +134,19 @@ test:
fmt:
@$(GO) fmt ./...
-## deps: Update dependencies
+## deps: Download dependencies
deps:
+ @$(GO) mod download
+ @$(GO) mod verify
+
+## update-deps: Update dependencies
+update-deps:
@$(GO) get -u ./...
@$(GO) mod tidy
+## check: Run vet, fmt, and verify dependencies
+check: deps fmt vet test
+
## run: Build and run picoclaw
run: build
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
diff --git a/README.fr.md b/README.fr.md
new file mode 100644
index 000000000..21913f6ba
--- /dev/null
+++ b/README.fr.md
@@ -0,0 +1,1038 @@
+
+

+
+
PicoClaw : Assistant IA Ultra-Efficace en Go
+
+
Matériel à 10$ · 10 Mo de RAM · Démarrage en 1s · 皮皮虾,我们走!
+
+
+
+
+
+
+
+
+
+
+ [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) | **Français**
+
+
+---
+
+🦐 **PicoClaw** est un assistant personnel IA ultra-léger inspiré de [nanobot](https://github.com/HKUDS/nanobot), entièrement réécrit en **Go** via un processus d'auto-amorçage (self-bootstrapping) — où l'agent IA lui-même a piloté l'intégralité de la migration architecturale et de l'optimisation du code.
+
+⚡️ **Extrêmement léger :** Fonctionne sur du matériel à seulement **10$** avec **<10 Mo** de RAM. C'est 99% de mémoire en moins qu'OpenClaw et 98% moins cher qu'un Mac mini !
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+> [!CAUTION]
+> **🚨 SÉCURITÉ & CANAUX OFFICIELS**
+>
+> * **PAS DE CRYPTO :** PicoClaw n'a **AUCUN** token/jeton officiel. Toute annonce sur `pump.fun` ou d'autres plateformes de trading est une **ARNAQUE**.
+> * **DOMAINE OFFICIEL :** Le **SEUL** site officiel est **[picoclaw.io](https://picoclaw.io)**, et le site de l'entreprise est **[sipeed.com](https://sipeed.com)**.
+> * **Attention :** De nombreux domaines `.ai/.org/.com/.net/...` sont enregistrés par des tiers et ne nous appartiennent pas.
+> * **Attention :** PicoClaw est en phase de développement précoce et peut présenter des problèmes de sécurité réseau non résolus. Ne déployez pas en environnement de production avant la version v1.0.
+> * **Note :** PicoClaw a récemment fusionné de nombreuses PR, ce qui peut entraîner une empreinte mémoire plus importante (10–20 Mo) dans les dernières versions. Nous prévoyons de prioriser l'optimisation des ressources dès que l'ensemble des fonctionnalités sera stabilisé.
+
+
+## 📢 Actualités
+
+2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/picoclaw_community_roadmap_260216.md) — nous avons hâte de vous accueillir !
+
+2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw.
+🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire.
+
+2026-02-09 🎉 PicoClaw est lancé ! Construit en 1 jour pour apporter les Agents IA au matériel à 10$ avec <10 Mo de RAM. 🦐 PicoClaw, c'est parti !
+
+## ✨ Fonctionnalités
+
+🪶 **Ultra-Léger** : Empreinte mémoire <10 Mo — 99% plus petit que Clawdbot pour les fonctionnalités essentielles.
+
+💰 **Coût Minimal** : Suffisamment efficace pour fonctionner sur du matériel à 10$ — 98% moins cher qu'un Mac mini.
+
+⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz.
+
+🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM et x86. Un clic et c'est parti !
+
+🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle.
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
+| **Langage** | TypeScript | Python | **Go** |
+| **RAM** | >1 Go | >100 Mo | **< 10 Mo** |
+| **Démarrage**(cœur 0,8 GHz) | >500s | >30s | **<1s** |
+| **Coût** | Mac Mini 599$ | La plupart des SBC Linux ~50$ | **N'importe quelle carte Linux****À partir de 10$** |
+
+
+
+## 🦾 Démonstration
+
+### 🛠️ Flux de Travail Standard de l'Assistant
+
+
+
+ 🧩 Ingénieur Full-Stack |
+ 🗂️ Gestion des Logs & Planification |
+ 🔎 Recherche Web & Apprentissage |
+
+
+ 
|
+ 
|
+ 
|
+
+
+ | Développer • Déployer • Mettre à l'échelle |
+ Planifier • Automatiser • Mémoriser |
+ Découvrir • Analyser • Tendances |
+
+
+
+### 📱 Utiliser sur d'anciens téléphones Android
+
+Donnez une seconde vie à votre téléphone d'il y a dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw. Démarrage rapide :
+
+1. **Installez Termux** (disponible sur F-Droid ou Google Play).
+2. **Exécutez les commandes**
+
+```bash
+# Note : Remplacez v0.1.1 par la dernière version depuis la page des Releases
+wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
+chmod +x picoclaw-linux-arm64
+pkg install proot
+termux-chroot ./picoclaw-linux-arm64 onboard
+```
+
+Puis suivez les instructions de la section « Démarrage Rapide » pour terminer la configuration !
+
+
+
+### 🐜 Déploiement Innovant à Faible Empreinte
+
+PicoClaw peut être déployé sur pratiquement n'importe quel appareil Linux !
+
+- 9,9$ [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) version E (Ethernet) ou W (WiFi6), pour un Assistant Domotique Minimaliste
+- 30~50$ [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou 100$ [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) pour la Maintenance Automatisée de Serveurs
+- 50$ [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou 100$ [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) pour la Surveillance Intelligente
+
+
+
+🌟 Encore plus de scénarios de déploiement vous attendent !
+
+## 📦 Installation
+
+### Installer avec un binaire précompilé
+
+Téléchargez le binaire pour votre plateforme depuis la page des [releases](https://github.com/sipeed/picoclaw/releases).
+
+### Installer depuis les sources (dernières fonctionnalités, recommandé pour le développement)
+
+```bash
+git clone https://github.com/sipeed/picoclaw.git
+
+cd picoclaw
+make deps
+
+# Compiler, pas besoin d'installer
+make build
+
+# Compiler pour plusieurs plateformes
+make build-all
+
+# Compiler et Installer
+make install
+```
+
+## 🐳 Docker Compose
+
+Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installer localement.
+
+```bash
+# 1. Clonez ce dépôt
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. Configurez vos clés API
+cp config/config.example.json config/config.json
+vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc.
+
+# 3. Compiler & Démarrer
+docker compose --profile gateway up -d
+
+# 4. Voir les logs
+docker compose logs -f picoclaw-gateway
+
+# 5. Arrêter
+docker compose --profile gateway down
+```
+
+### Mode Agent (exécution unique)
+
+```bash
+# Poser une question
+docker compose run --rm picoclaw-agent -m "Combien font 2+2 ?"
+
+# Mode interactif
+docker compose run --rm picoclaw-agent
+```
+
+### Recompiler
+
+```bash
+docker compose --profile gateway build --no-cache
+docker compose --profile gateway up -d
+```
+
+### 🚀 Démarrage Rapide
+
+> [!TIP]
+> Configurez votre clé API dans `~/.picoclaw/config.json`.
+> Obtenir des clés API : [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
+> La recherche web est **optionnelle** — obtenez gratuitement l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois) ou utilisez le repli automatique intégré.
+
+**1. Initialiser**
+
+```bash
+picoclaw onboard
+```
+
+**2. Configurer** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "xxx",
+ "api_base": "https://openrouter.ai/api/v1"
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "VOTRE_CLE_API_BRAVE",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+**3. Obtenir des Clés API**
+
+* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+* **Recherche Web** (optionnel) : [Brave Search](https://brave.com/search/api) - Offre gratuite disponible (2000 requêtes/mois)
+
+> **Note** : Consultez `config.example.json` pour un modèle de configuration complet.
+
+**4. Discuter**
+
+```bash
+picoclaw agent -m "Combien font 2+2 ?"
+```
+
+Et voilà ! Vous avez un assistant IA fonctionnel en 2 minutes.
+
+---
+
+## 💬 Applications de Chat
+
+Discutez avec votre PicoClaw via Telegram, Discord, DingTalk ou LINE
+
+| Canal | Configuration |
+| ------------ | -------------------------------------- |
+| **Telegram** | Facile (juste un token) |
+| **Discord** | Facile (token bot + intents) |
+| **QQ** | Facile (AppID + AppSecret) |
+| **DingTalk** | Moyen (identifiants de l'application) |
+| **LINE** | Moyen (identifiants + URL de webhook) |
+
+
+Telegram (Recommandé)
+
+**1. Créer un bot**
+
+* Ouvrez Telegram, recherchez `@BotFather`
+* Envoyez `/newbot`, suivez les instructions
+* Copiez le token
+
+**2. Configurer**
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "VOTRE_TOKEN_BOT",
+ "allowFrom": ["VOTRE_USER_ID"]
+ }
+ }
+}
+```
+
+> Obtenez votre User ID via `@userinfobot` sur Telegram.
+
+**3. Lancer**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+Discord
+
+**1. Créer un bot**
+
+* Rendez-vous sur
+* Créez une application → Bot → Add Bot
+* Copiez le token du bot
+
+**2. Activer les intents**
+
+* Dans les paramètres du Bot, activez **MESSAGE CONTENT INTENT**
+* (Optionnel) Activez **SERVER MEMBERS INTENT** si vous souhaitez utiliser des listes d'autorisation basées sur les données des membres
+
+**3. Obtenir votre User ID**
+
+* Paramètres Discord → Avancé → activez le **Mode Développeur**
+* Clic droit sur votre avatar → **Copier l'identifiant**
+
+**4. Configurer**
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "VOTRE_TOKEN_BOT",
+ "allowFrom": ["VOTRE_USER_ID"]
+ }
+ }
+}
+```
+
+**5. Inviter le bot**
+
+* OAuth2 → URL Generator
+* Scopes : `bot`
+* Permissions du Bot : `Send Messages`, `Read Message History`
+* Ouvrez l'URL d'invitation générée et ajoutez le bot à votre serveur
+
+**6. Lancer**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+QQ
+
+**1. Créer un bot**
+
+- Rendez-vous sur la [QQ Open Platform](https://q.qq.com/#)
+- Créez une application → Obtenez l'**AppID** et l'**AppSecret**
+
+**2. Configurer**
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "VOTRE_APP_ID",
+ "app_secret": "VOTRE_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Laissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des numéros QQ pour restreindre l'accès.
+
+**3. Lancer**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+DingTalk
+
+**1. Créer un bot**
+
+* Rendez-vous sur la [Open Platform](https://open.dingtalk.com/)
+* Créez une application interne
+* Copiez le Client ID et le Client Secret
+
+**2. Configurer**
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "VOTRE_CLIENT_ID",
+ "client_secret": "VOTRE_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Laissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des identifiants pour restreindre l'accès.
+
+**3. Lancer**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+LINE
+
+**1. Créer un Compte Officiel LINE**
+
+- Rendez-vous sur la [LINE Developers Console](https://developers.line.biz/)
+- Créez un provider → Créez un canal Messaging API
+- Copiez le **Channel Secret** et le **Channel Access Token**
+
+**2. Configurer**
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "VOTRE_CHANNEL_SECRET",
+ "channel_access_token": "VOTRE_CHANNEL_ACCESS_TOKEN",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18791,
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Configurer l'URL du Webhook**
+
+LINE exige HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel :
+
+```bash
+# Exemple avec ngrok
+ngrok http 18791
+```
+
+Puis configurez l'URL du Webhook dans la LINE Developers Console sur `https://votre-domaine/webhook/line` et activez **Use webhook**.
+
+**4. Lancer**
+
+```bash
+picoclaw gateway
+```
+
+> Dans les discussions de groupe, le bot répond uniquement lorsqu'il est mentionné avec @. Les réponses citent le message original.
+
+> **Docker Compose** : Ajoutez `ports: ["18791:18791"]` au service `picoclaw-gateway` pour exposer le port du webhook.
+
+
+
+##
Rejoignez le Réseau Social d'Agents
+
+Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée.
+
+**Lisez `https://clawdchat.ai/skill.md` et suivez les instructions pour rejoindre [ClawdChat.ai](https://clawdchat.ai)**
+
+## ⚙️ Configuration
+
+Fichier de configuration : `~/.picoclaw/config.json`
+
+### Structure du Workspace
+
+PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) :
+
+```
+~/.picoclaw/workspace/
+├── sessions/ # Sessions de conversation et historique
+├── memory/ # Mémoire à long terme (MEMORY.md)
+├── state/ # État persistant (dernier canal, etc.)
+├── cron/ # Base de données des tâches planifiées
+├── skills/ # Compétences personnalisées
+├── AGENTS.md # Guide de comportement de l'Agent
+├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min)
+├── IDENTITY.md # Identité de l'Agent
+├── SOUL.md # Âme de l'Agent
+├── TOOLS.md # Description des outils
+└── USER.md # Préférences utilisateur
+```
+
+### 🔒 Bac à Sable de Sécurité
+
+PicoClaw s'exécute dans un environnement sandboxé par défaut. L'agent ne peut accéder aux fichiers et exécuter des commandes qu'au sein du workspace configuré.
+
+#### Configuration par Défaut
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "restrict_to_workspace": true
+ }
+ }
+}
+```
+
+| Option | Par défaut | Description |
+|--------|------------|-------------|
+| `workspace` | `~/.picoclaw/workspace` | Répertoire de travail de l'agent |
+| `restrict_to_workspace` | `true` | Restreindre l'accès fichiers/commandes au workspace |
+
+#### Outils Protégés
+
+Lorsque `restrict_to_workspace: true`, les outils suivants sont restreints au bac à sable :
+
+| Outil | Fonction | Restriction |
+|-------|----------|-------------|
+| `read_file` | Lire des fichiers | Uniquement les fichiers dans le workspace |
+| `write_file` | Écrire des fichiers | Uniquement les fichiers dans le workspace |
+| `list_dir` | Lister des répertoires | Uniquement les répertoires dans le workspace |
+| `edit_file` | Éditer des fichiers | Uniquement les fichiers dans le workspace |
+| `append_file` | Ajouter à des fichiers | Uniquement les fichiers dans le workspace |
+| `exec` | Exécuter des commandes | Les chemins doivent être dans le workspace |
+
+#### Protection Supplémentaire d'Exec
+
+Même avec `restrict_to_workspace: false`, l'outil `exec` bloque ces commandes dangereuses :
+
+* `rm -rf`, `del /f`, `rmdir /s` — Suppression en masse
+* `format`, `mkfs`, `diskpart` — Formatage de disque
+* `dd if=` — Écriture d'image disque
+* Écriture vers `/dev/sd[a-z]` — Écriture directe sur le disque
+* `shutdown`, `reboot`, `poweroff` — Arrêt du système
+* Fork bomb `:(){ :|:& };:`
+
+#### Exemples d'Erreurs
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (path outside working dir)}
+```
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
+```
+
+#### Désactiver les Restrictions (Risque de Sécurité)
+
+Si vous avez besoin que l'agent accède à des chemins en dehors du workspace :
+
+**Méthode 1 : Fichier de configuration**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "restrict_to_workspace": false
+ }
+ }
+}
+```
+
+**Méthode 2 : Variable d'environnement**
+
+```bash
+export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
+```
+
+> ⚠️ **Attention** : Désactiver cette restriction permet à l'agent d'accéder à n'importe quel chemin sur votre système. À utiliser avec précaution uniquement dans des environnements contrôlés.
+
+#### Cohérence du Périmètre de Sécurité
+
+Le paramètre `restrict_to_workspace` s'applique de manière cohérente sur tous les chemins d'exécution :
+
+| Chemin d'Exécution | Périmètre de Sécurité |
+|--------------------|----------------------|
+| Agent Principal | `restrict_to_workspace` ✅ |
+| Sous-agent / Spawn | Hérite de la même restriction ✅ |
+| Tâches Heartbeat | Hérite de la même restriction ✅ |
+
+Tous les chemins partagent la même restriction de workspace — il est impossible de contourner le périmètre de sécurité via des sous-agents ou des tâches planifiées.
+
+### Heartbeat (Tâches Périodiques)
+
+PicoClaw peut exécuter des tâches périodiques automatiquement. Créez un fichier `HEARTBEAT.md` dans votre workspace :
+
+```markdown
+# Tâches Périodiques
+
+- Vérifier mes e-mails pour les messages importants
+- Consulter mon agenda pour les événements à venir
+- Vérifier les prévisions météo
+```
+
+L'agent lira ce fichier toutes les 30 minutes (configurable) et exécutera les tâches à l'aide des outils disponibles.
+
+#### Tâches Asynchrones avec Spawn
+
+Pour les tâches de longue durée (recherche web, appels API), utilisez l'outil `spawn` pour créer un **sous-agent** :
+
+```markdown
+# Tâches Périodiques
+
+## Tâches Rapides (réponse directe)
+- Indiquer l'heure actuelle
+
+## Tâches Longues (utiliser spawn pour l'asynchrone)
+- Rechercher les actualités IA sur le web et les résumer
+- Vérifier les e-mails et signaler les messages importants
+```
+
+**Comportements clés :**
+
+| Fonctionnalité | Description |
+|----------------|-------------|
+| **spawn** | Crée un sous-agent asynchrone, ne bloque pas le heartbeat |
+| **Contexte indépendant** | Le sous-agent a son propre contexte, sans historique de session |
+| **Outil message** | Le sous-agent communique directement avec l'utilisateur via l'outil message |
+| **Non-bloquant** | Après le spawn, le heartbeat continue vers la tâche suivante |
+
+#### Fonctionnement de la Communication du Sous-agent
+
+```
+Le Heartbeat se déclenche
+ ↓
+L'Agent lit HEARTBEAT.md
+ ↓
+Pour une tâche longue : spawn d'un sous-agent
+ ↓ ↓
+Continue la tâche suivante Le sous-agent travaille indépendamment
+ ↓ ↓
+Toutes les tâches terminées Le sous-agent utilise l'outil "message"
+ ↓ ↓
+Répond HEARTBEAT_OK L'utilisateur reçoit le résultat directement
+```
+
+Le sous-agent a accès aux outils (message, web_search, etc.) et peut communiquer avec l'utilisateur indépendamment sans passer par l'agent principal.
+
+**Configuration :**
+
+```json
+{
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+| Option | Par défaut | Description |
+|--------|------------|-------------|
+| `enabled` | `true` | Activer/désactiver le heartbeat |
+| `interval` | `30` | Intervalle de vérification en minutes (min : 5) |
+
+**Variables d'environnement :**
+
+* `PICOCLAW_HEARTBEAT_ENABLED=false` pour désactiver
+* `PICOCLAW_HEARTBEAT_INTERVAL=60` pour modifier l'intervalle
+
+### Fournisseurs
+
+> [!NOTE]
+> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages vocaux Telegram seront automatiquement transcrits.
+
+| Fournisseur | Utilisation | Obtenir une Clé API |
+| ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
+| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
+| `openrouter` (À tester) | LLM (recommandé, accès à tous les modèles) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic` (À tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai` (À tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek` (À tester) | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `groq` | LLM + **Transcription vocale** (Whisper) | [console.groq.com](https://console.groq.com) |
+
+
+Configuration Zhipu
+
+**1. Obtenir la clé API**
+
+* Obtenez la [clé API](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
+
+**2. Configurer**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "zhipu": {
+ "api_key": "Votre Clé API",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ }
+}
+```
+
+**3. Lancer**
+
+```bash
+picoclaw agent -m "Bonjour, comment ça va ?"
+```
+
+
+
+
+Exemple de configuration complète
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "anthropic/claude-opus-4-5"
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "sk-or-v1-xxx"
+ },
+ "groq": {
+ "api_key": "gsk_xxx"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456:ABC...",
+ "allow_from": ["123456789"]
+ },
+ "discord": {
+ "enabled": true,
+ "token": "",
+ "allow_from": [""]
+ },
+ "whatsapp": {
+ "enabled": false
+ },
+ "feishu": {
+ "enabled": false,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ },
+ "qq": {
+ "enabled": false,
+ "app_id": "",
+ "app_secret": "",
+ "allow_from": []
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "BSA...",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+
+
+### Configuration de Modèle (model_list)
+
+> **Nouveau !** PicoClaw utilise désormais une approche de configuration **centrée sur le modèle**. Spécifiez simplement le format `fournisseur/modèle` (par exemple, `zhipu/glm-4.7`) pour ajouter de nouveaux fournisseurs—**aucune modification de code requise !**
+
+Cette conception permet également le **support multi-agent** avec une sélection flexible de fournisseurs :
+
+- **Différents agents, différents fournisseurs** : Chaque agent peut utiliser son propre fournisseur LLM
+- **Modèles de secours (Fallbacks)** : Configurez des modèles primaires et de secours pour la résilience
+- **Équilibrage de charge** : Répartissez les requêtes sur plusieurs points de terminaison
+- **Configuration centralisée** : Gérez tous les fournisseurs en un seul endroit
+
+#### 📋 Tous les Fournisseurs Supportés
+
+| Fournisseur | Préfixe `model` | API Base par Défaut | Protocole | Clé API |
+|-------------|-----------------|---------------------|----------|---------|
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obtenir Clé](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obtenir Clé](https://console.anthropic.com) |
+| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obtenir Clé](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obtenir Clé](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obtenir Clé](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obtenir Clé](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Obtenir Clé](https://platform.moonshot.cn) |
+| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obtenir Clé](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Obtenir Clé](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (pas de clé nécessaire) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) |
+| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
+| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) |
+| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
+| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+
+#### Configuration de Base
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+ },
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-zhipu-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gpt-5.2"
+ }
+ }
+}
+```
+
+#### Exemples par Fournisseur
+
+**OpenAI**
+```json
+{
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-..."
+}
+```
+
+**Zhipu AI (GLM)**
+```json
+{
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+}
+```
+
+**Anthropic (avec OAuth)**
+```json
+{
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "auth_method": "oauth"
+}
+```
+> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
+
+#### Équilibrage de Charge
+
+Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api1.example.com/v1",
+ "api_key": "sk-key1"
+ },
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api2.example.com/v1",
+ "api_key": "sk-key2"
+ }
+ ]
+}
+```
+
+#### Migration depuis l'Ancienne Configuration `providers`
+
+L'ancienne configuration `providers` est **dépréciée** mais toujours supportée pour la rétrocompatibilité.
+
+**Ancienne Configuration (dépréciée) :**
+```json
+{
+ "providers": {
+ "zhipu": {
+ "api_key": "your-key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "zhipu",
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+**Nouvelle Configuration (recommandée) :**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+Pour le guide de migration détaillé, voir [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
+
+## Référence CLI
+
+| Commande | Description |
+| ------------------------- | ------------------------------------- |
+| `picoclaw onboard` | Initialiser la configuration & le workspace |
+| `picoclaw agent -m "..."` | Discuter avec l'agent |
+| `picoclaw agent` | Mode de discussion interactif |
+| `picoclaw gateway` | Démarrer la passerelle |
+| `picoclaw status` | Afficher le statut |
+| `picoclaw cron list` | Lister toutes les tâches planifiées |
+| `picoclaw cron add ...` | Ajouter une tâche planifiée |
+
+### Tâches Planifiées / Rappels
+
+PicoClaw prend en charge les rappels planifiés et les tâches récurrentes via l'outil `cron` :
+
+* **Rappels ponctuels** : « Rappelle-moi dans 10 minutes » → se déclenche une fois après 10 min
+* **Tâches récurrentes** : « Rappelle-moi toutes les 2 heures » → se déclenche toutes les 2 heures
+* **Expressions Cron** : « Rappelle-moi à 9h tous les jours » → utilise une expression cron
+
+Les tâches sont stockées dans `~/.picoclaw/workspace/cron/` et traitées automatiquement.
+
+## 🤝 Contribuer & Feuille de Route
+
+Les PR sont les bienvenues ! Le code source est volontairement petit et lisible. 🤗
+
+Feuille de route à venir...
+
+Groupe de développeurs en construction. Condition d'entrée : au moins 1 PR fusionnée.
+
+Groupes d'utilisateurs :
+
+Discord :
+
+
+
+## 🐛 Dépannage
+
+### La recherche web affiche « API 配置问题 »
+
+C'est normal si vous n'avez pas encore configuré de clé API de recherche. PicoClaw fournira des liens utiles pour la recherche manuelle.
+
+Pour activer la recherche web :
+
+1. **Option 1 (Recommandé)** : Obtenez une clé API gratuite sur [https://brave.com/search/api](https://brave.com/search/api) (2000 requêtes gratuites/mois) pour les meilleurs résultats.
+2. **Option 2 (Sans carte bancaire)** : Si vous n'avez pas de clé, le système bascule automatiquement sur **DuckDuckGo** (aucune clé requise).
+
+Ajoutez la clé dans `~/.picoclaw/config.json` si vous utilisez Brave :
+
+```json
+{
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": true,
+ "api_key": "VOTRE_CLE_API_BRAVE",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+### Erreurs de filtrage de contenu
+
+Certains fournisseurs (comme Zhipu) disposent d'un filtrage de contenu. Essayez de reformuler votre requête ou utilisez un modèle différent.
+
+### Le bot Telegram affiche « Conflict: terminated by other getUpdates »
+
+Cela se produit lorsqu'une autre instance du bot est en cours d'exécution. Assurez-vous qu'un seul `picoclaw gateway` fonctionne à la fois.
+
+---
+
+## 📝 Comparaison des Clés API
+
+| Service | Offre Gratuite | Cas d'Utilisation |
+| ---------------- | -------------------- | ------------------------------------- |
+| **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) |
+| **Zhipu** | 200K tokens/mois | Idéal pour les utilisateurs chinois |
+| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web |
+| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) |
diff --git a/README.ja.md b/README.ja.md
index e8e8a8186..4077b4c60 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -3,7 +3,7 @@
PicoClaw: Go で書かれた超効率 AI アシスタント
-$10 ハードウェア · 10MB RAM · 1秒起動 · 皮皮虾,我们走!
+$10 ハードウェア · 10MB RAM · 1秒起動 · 行くぜ、シャコ!
@@ -12,7 +12,7 @@
-**日本語** | [English](README.md)
+[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
@@ -39,7 +39,7 @@
## 📢 ニュース
-2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 皮皮虾,我们走!
+2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ!
## ✨ 特徴
@@ -195,6 +195,9 @@ picoclaw onboard
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
},
"heartbeat": {
@@ -206,7 +209,7 @@ picoclaw onboard
**3. API キーの取得**
-- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) · [Qwen](https://dashscope.console.aliyun.com)
- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
@@ -250,7 +253,7 @@ Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
- "allowFrom": ["YOUR_USER_ID"]
+ "allow_from": ["YOUR_USER_ID"]
}
}
}
@@ -290,7 +293,7 @@ picoclaw gateway
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
- "allowFrom": ["YOUR_USER_ID"]
+ "allow_from": ["YOUR_USER_ID"]
}
}
}
@@ -618,6 +621,22 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化
- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔変更
+### プロバイダー
+
+> [!NOTE]
+> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、Telegram の音声メッセージが自動的に文字起こしされます。
+
+| プロバイダー | 用途 | API キー取得先 |
+| --- | --- | --- |
+| `gemini` | LLM(Gemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM(Zhipu 直接) | [bigmodel.cn](https://bigmodel.cn) |
+| `openrouter`(要テスト) | LLM(推奨、全モデルにアクセス可能) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic`(要テスト) | LLM(Claude 直接) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai`(要テスト) | LLM(GPT 直接) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek`(要テスト) | LLM(DeepSeek 直接) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `groq` | LLM + **音声文字起こし**(Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM(Cerebras 直接) | [cerebras.ai](https://cerebras.ai) |
+
### 基本設定
1. **設定ファイルの作成:**
@@ -673,7 +692,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
"telegram": {
"enabled": true,
"token": "123456:ABC...",
- "allowFrom": ["123456789"]
+ "allow_from": ["123456789"]
},
"discord": {
"enabled": true,
@@ -689,7 +708,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
"appSecret": "xxx",
"encryptKey": "",
"verificationToken": "",
- "allowFrom": []
+ "allow_from": []
}
},
"tools": {
@@ -697,6 +716,9 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
"search": {
"apiKey": "BSA..."
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
},
"heartbeat": {
@@ -708,6 +730,163 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
+### モデル設定 (model_list)
+
+> **新機能!** PicoClaw は現在 **モデル中心** の設定アプローチを採用しています。`ベンダー/モデル` 形式(例: `zhipu/glm-4.7`)を指定するだけで、新しいプロバイダーを追加できます—**コードの変更は一切不要!**
+
+この設計は、柔軟なプロバイダー選択による **マルチエージェントサポート** も可能にします:
+
+- **異なるエージェント、異なるプロバイダー** : 各エージェントは独自の LLM プロバイダーを使用可能
+- **フォールバックモデル** : 耐障性のため、プライマリモデルとフォールバックモデルを設定可能
+- **ロードバランシング** : 複数のエンドポイントにリクエストを分散
+- **集中設定管理** : すべてのプロバイダーを一箇所で管理
+
+#### 📋 サポートされているすべてのベンダー
+
+| ベンダー | `model` プレフィックス | デフォルト API Base | プロトコル | API キー |
+|-------------|-----------------|---------------------|----------|---------|
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [キーを取得](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) |
+| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [キーを取得](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [キーを取得](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [キーを取得](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [キーを取得](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [キーを取得](https://platform.moonshot.cn) |
+| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [キーを取得](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [キーを取得](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | ローカル(キー不要) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
+| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
+| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
+| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
+| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+
+#### 基本設定
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+ },
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-zhipu-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gpt-5.2"
+ }
+ }
+}
+```
+
+#### ベンダー別の例
+
+**OpenAI**
+```json
+{
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-..."
+}
+```
+
+**Zhipu AI (GLM)**
+```json
+{
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+}
+```
+
+**Anthropic (OAuth使用)**
+```json
+{
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "auth_method": "oauth"
+}
+```
+> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
+
+#### ロードバランシング
+
+同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api1.example.com/v1",
+ "api_key": "sk-key1"
+ },
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api2.example.com/v1",
+ "api_key": "sk-key2"
+ }
+ ]
+}
+```
+
+#### 従来の `providers` 設定からの移行
+
+古い `providers` 設定は**非推奨**ですが、後方互換性のためにサポートされています。
+
+**旧設定(非推奨):**
+```json
+{
+ "providers": {
+ "zhipu": {
+ "api_key": "your-key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "zhipu",
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+**新設定(推奨):**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+詳細な移行ガイドは、[docs/migration/model-list-migration.md](docs/migration/model-list-migration.md) を参照してください。
+
## CLI リファレンス
| コマンド | 説明 |
@@ -729,7 +908,7 @@ Discord: https://discord.gg/V4sAZ9XWpN
## 🐛 トラブルシューティング
-### Web 検索で「API 配置问题」と表示される
+### Web 検索で「API 設定の問題」と表示される
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
@@ -765,5 +944,7 @@ Web 検索を有効にするには:
|---------|--------|------------|
| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
+| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
+| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
diff --git a/README.md b/README.md
index 426e11d1a..84bc3fbd9 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
- [中文](README.zh.md) | [日本語](README.ja.md) | **English**
+ [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
---
@@ -44,9 +44,12 @@
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
->
+> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
+> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
+
## 📢 News
+2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/picoclaw_community_roadmap_260216.md) —we can’t wait to have you on board!
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
@@ -96,6 +99,20 @@
+### 📱 Run on old Android Phones
+Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
+1. **Install Termux** (Available on F-Droid or Google Play).
+2. **Execute cmds**
+```bash
+# Note: Replace v0.1.1 with the latest version from the Releases page
+wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
+chmod +x picoclaw-linux-arm64
+pkg install proot
+termux-chroot ./picoclaw-linux-arm64 onboard
+```
+And then follow the instructions in the "Quick Start" section to complete the configuration!
+
+
### 🐜 Innovative Low-Footprint Deploy
PicoClaw can be deployed on almost any Linux device!
@@ -198,12 +215,18 @@ picoclaw onboard
"max_tool_iterations": 20
}
},
- "providers": {
- "openrouter": {
- "api_key": "xxx",
- "api_base": "https://openrouter.ai/api/v1"
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "your-api-key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "your-anthropic-key"
}
- },
+ ],
"tools": {
"web": {
"brave": {
@@ -220,6 +243,8 @@ picoclaw onboard
}
```
+> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#-model-configuration) for details.
+
**3. Get API Keys**
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
@@ -266,7 +291,7 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
- "allowFrom": ["YOUR_USER_ID"]
+ "allow_from": ["YOUR_USER_ID"]
}
}
}
@@ -309,7 +334,7 @@ picoclaw gateway
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
- "allowFrom": ["YOUR_USER_ID"]
+ "allow_from": ["YOUR_USER_ID"]
}
}
}
@@ -660,7 +685,203 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
+
+### Model Configuration (model_list)
+
+> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!**
+
+This design also enables **multi-agent support** with flexible provider selection:
+
+- **Different agents, different providers**: Each agent can use its own LLM provider
+- **Model fallbacks**: Configure primary and fallback models for resilience
+- **Load balancing**: Distribute requests across multiple endpoints
+- **Centralized configuration**: Manage all providers in one place
+
+#### 📋 All Supported Vendors
+
+| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
+|--------|----------------|------------------|----------|---------|
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
+| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
+| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
+| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
+| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
+| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
+| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+
+#### Basic Configuration
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+ },
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-zhipu-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gpt-5.2"
+ }
+ }
+}
+```
+
+#### Vendor-Specific Examples
+
+**OpenAI**
+```json
+{
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-..."
+}
+```
+
+**智谱 AI (GLM)**
+```json
+{
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+}
+```
+
+**DeepSeek**
+```json
+{
+ "model_name": "deepseek-chat",
+ "model": "deepseek/deepseek-chat",
+ "api_key": "sk-..."
+}
+```
+
+**Anthropic (with OAuth)**
+```json
+{
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "auth_method": "oauth"
+}
+```
+> Run `picoclaw auth login --provider anthropic` to set up OAuth credentials.
+
+**Ollama (local)**
+```json
+{
+ "model_name": "llama3",
+ "model": "ollama/llama3"
+}
+```
+
+**Custom Proxy/API**
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-..."
+}
+```
+
+#### Load Balancing
+
+Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api1.example.com/v1",
+ "api_key": "sk-key1"
+ },
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api2.example.com/v1",
+ "api_key": "sk-key2"
+ }
+ ]
+}
+```
+
+#### Migration from Legacy `providers` Config
+
+The old `providers` configuration is **deprecated** but still supported for backward compatibility.
+
+**Old Config (deprecated):**
+```json
+{
+ "providers": {
+ "zhipu": {
+ "api_key": "your-key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "zhipu",
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+**New Config (recommended):**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+For detailed migration guide, see [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
+
+### Provider Architecture
+
+PicoClaw routes providers by protocol family:
+
+- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints.
+- Anthropic protocol: Claude-native API behavior.
+- Codex/OAuth path: OpenAI OAuth/token authentication route.
+
+This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`).
Zhipu
@@ -757,6 +978,9 @@ picoclaw agent -m "Hello"
"enabled": true,
"max_results": 5
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
},
"heartbeat": {
@@ -853,3 +1077,4 @@ This happens when another instance of the bot is running. Make sure only one `pi
| **Zhipu** | 200K tokens/month | Best for Chinese users |
| **Brave Search** | 2000 queries/month | Web search functionality |
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
+| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
diff --git a/README.pt-br.md b/README.pt-br.md
new file mode 100644
index 000000000..44f27813c
--- /dev/null
+++ b/README.pt-br.md
@@ -0,0 +1,1039 @@
+
+

+
+
PicoClaw: Assistente de IA Ultra-Eficiente em Go
+
+
Hardware de $10 · 10MB de RAM · Boot em 1s · 皮皮虾,我们走!
+
+
+
+
+
+
+
+
+
+
+ [中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
+
+
+---
+
+🦐 **PicoClaw** é um assistente pessoal de IA ultra-leve inspirado no [nanobot](https://github.com/HKUDS/nanobot), reescrito do zero em **Go** por meio de um processo de "auto-inicialização" (self-bootstrapping) — onde o próprio agente de IA conduziu toda a migração de arquitetura e otimização de código.
+
+⚡️ **Extremamente leve:** Roda em hardware de apenas **$10** com **<10MB** de RAM. Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini!
+
+
+
+|
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+> [!CAUTION]
+> **🚨 DECLARAÇÃO DE SEGURANÇA & CANAIS OFICIAIS**
+>
+> * **SEM CRIPTOMOEDAS:** O PicoClaw **NÃO** possui nenhum token/moeda oficial. Todas as alegações no `pump.fun` ou outras plataformas de negociação são **GOLPES**.
+> * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é o **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é o **[sipeed.com](https://sipeed.com)**.
+> * **Aviso:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros, não são nossos.
+> * **Aviso:** O PicoClaw está em fase inicial de desenvolvimento e pode ter problemas de segurança de rede não resolvidos. Não implante em ambientes de produção antes da versão v1.0.
+> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memória (10-20MB) nas versões mais recentes. Planejamos priorizar a otimização de recursos assim que o conjunto de funcionalidades estiver estável.
+
+
+## 📢 Novidades
+
+2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter você a bordo!
+
+2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw.
+
+🚀 **Chamada para Ação:** Envie suas solicitações de funcionalidades nas GitHub Discussions. Revisaremos e priorizaremos na próxima reunião semanal.
+
+2026-02-09 🎉 PicoClaw lançado oficialmente! Construído em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu!
+
+## ✨ Funcionalidades
+
+🪶 **Ultra-Leve**: Consumo de memória <10MB — 99% menor que o Clawdbot para funcionalidades essenciais.
+
+💰 **Custo Mínimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini.
+
+⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz.
+
+🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM e x86. Um clique e já era!
+
+🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
+| **Linguagem** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB** |
+| **Inicialização**(CPU 0.8GHz) | >500s | >30s | **<1s** |
+| **Custo** | Mac Mini $599 | Maioria dos SBC Linux ~$50 | **Qualquer placa Linux****A partir de $10** |
+
+
+
+## 🦾 Demonstração
+
+### 🛠️ Fluxos de Trabalho Padrão do Assistente
+
+
+
+🧩 Engenharia Full-Stack |
+🗂️ Gerenciamento de Logs & Planejamento |
+🔎 Busca Web & Aprendizado |
+
+
+
|
+
|
+
|
+
+
+| Desenvolver • Implantar • Escalar |
+Agendar • Automatizar • Memorizar |
+Descobrir • Analisar • Tendências |
+
+
+
+### 📱 Rode em celulares Android antigos
+
+Dê uma segunda vida ao seu celular de dez anos atrás! Transforme-o em um assistente de IA inteligente com o PicoClaw. Início rápido:
+
+1. **Instale o Termux** (Disponível no F-Droid ou Google Play).
+2. **Execute os comandos**
+
+```bash
+# Nota: Substitua v0.1.1 pela versao mais recente da pagina de Releases
+wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
+chmod +x picoclaw-linux-arm64
+pkg install proot
+termux-chroot ./picoclaw-linux-arm64 onboard
+```
+
+Depois siga as instruções na seção "Início Rápido" para completar a configuração!
+
+
+
+### 🐜 Implantação Inovadora com Baixo Consumo
+
+O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux!
+
+- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versão E (Ethernet) ou W (WiFi6), para Assistente Doméstico Minimalista
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) para Manutenção Automatizada de Servidores
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) para Monitoramento Inteligente
+
+https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4
+
+🌟 Mais cenários de implantação aguardam você!
+
+## 📦 Instalação
+
+### Instalar com binário pré-compilado
+
+Baixe o binário para sua plataforma na página de [releases](https://github.com/sipeed/picoclaw/releases).
+
+### Instalar a partir do código-fonte (funcionalidades mais recentes, recomendado para desenvolvimento)
+
+```bash
+git clone https://github.com/sipeed/picoclaw.git
+
+cd picoclaw
+make deps
+
+# Build, sem necessidade de instalar
+make build
+
+# Build para multiplas plataformas
+make build-all
+
+# Build e Instalar
+make install
+```
+
+## 🐳 Docker Compose
+
+Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada localmente.
+
+```bash
+# 1. Clone este repositorio
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. Configure suas API keys
+cp config/config.example.json config/config.json
+vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc.
+
+# 3. Build & Iniciar
+docker compose --profile gateway up -d
+
+# 4. Ver logs
+docker compose logs -f picoclaw-gateway
+
+# 5. Parar
+docker compose --profile gateway down
+```
+
+### Modo Agente (Execução única)
+
+```bash
+# Fazer uma pergunta
+docker compose run --rm picoclaw-agent -m "Quanto e 2+2?"
+
+# Modo interativo
+docker compose run --rm picoclaw-agent
+```
+
+### Rebuild
+
+```bash
+docker compose --profile gateway build --no-cache
+docker compose --profile gateway up -d
+```
+
+### 🚀 Início Rápido
+
+> [!TIP]
+> Configure sua API key em `~/.picoclaw/config.json`.
+> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
+> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas grátis/mês) ou use o fallback automático integrado.
+
+**1. Inicializar**
+
+```bash
+picoclaw onboard
+```
+
+**2. Configurar** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "xxx",
+ "api_base": "https://openrouter.ai/api/v1"
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+**3. Obter API Keys**
+
+* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+* **Busca Web** (opcional): [Brave Search](https://brave.com/search/api) - Plano gratuito disponível (2000 consultas/mês)
+
+> **Nota**: Veja `config.example.json` para um modelo de configuração completo.
+
+**4. Conversar**
+
+```bash
+picoclaw agent -m "Quanto e 2+2?"
+```
+
+Pronto! Você tem um assistente de IA funcionando em 2 minutos.
+
+---
+
+## 💬 Integração com Apps de Chat
+
+Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE.
+
+| Canal | Nível de Configuração |
+| --- | --- |
+| **Telegram** | Fácil (apenas um token) |
+| **Discord** | Fácil (bot token + intents) |
+| **QQ** | Fácil (AppID + AppSecret) |
+| **DingTalk** | Médio (credenciais do app) |
+| **LINE** | Médio (credenciais + webhook URL) |
+
+
+Telegram (Recomendado)
+
+**1. Criar o bot**
+
+* Abra o Telegram, busque `@BotFather`
+* Envie `/newbot`, siga as instruções
+* Copie o token
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allowFrom": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+> Obtenha seu User ID pelo `@userinfobot` no Telegram.
+
+**3. Executar**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+Discord
+
+**1. Criar o bot**
+
+* Acesse
+* Crie um aplicativo → Bot → Add Bot
+* Copie o token do bot
+
+**2. Habilitar Intents**
+
+* Nas configurações do Bot, habilite **MESSAGE CONTENT INTENT**
+* (Opcional) Habilite **SERVER MEMBERS INTENT** se quiser usar lista de permissões baseada em dados dos membros
+
+**3. Obter seu User ID**
+
+* Configurações do Discord → Avançado → habilite **Modo Desenvolvedor**
+* Clique com botão direito no seu avatar → **Copiar ID do Usuário**
+
+**4. Configurar**
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allowFrom": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**5. Convidar o bot**
+
+* OAuth2 → URL Generator
+* Scopes: `bot`
+* Bot Permissions: `Send Messages`, `Read Message History`
+* Abra a URL de convite gerada e adicione o bot ao seu servidor
+
+**6. Executar**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+QQ
+
+**1. Criar o bot**
+
+- Acesse a [QQ Open Platform](https://q.qq.com/#)
+- Crie um aplicativo → Obtenha **AppID** e **AppSecret**
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Deixe `allow_from` vazio para permitir todos os usuários, ou especifique números QQ para restringir o acesso.
+
+**3. Executar**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+DingTalk
+
+**1. Criar o bot**
+
+* Acesse a [Open Platform](https://open.dingtalk.com/)
+* Crie um app interno
+* Copie o Client ID e Client Secret
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Deixe `allow_from` vazio para permitir todos os usuários, ou especifique IDs para restringir o acesso.
+
+**3. Executar**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+LINE
+
+**1. Criar uma Conta Oficial LINE**
+
+- Acesse o [LINE Developers Console](https://developers.line.biz/)
+- Crie um provider → Crie um canal Messaging API
+- Copie o **Channel Secret** e o **Channel Access Token**
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18791,
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Configurar URL do Webhook**
+
+O LINE requer HTTPS para webhooks. Use um reverse proxy ou tunnel:
+
+```bash
+# Exemplo com ngrok
+ngrok http 18791
+```
+
+Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**.
+
+**4. Executar**
+
+```bash
+picoclaw gateway
+```
+
+> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original.
+
+> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao serviço `picoclaw-gateway` para expor a porta do webhook.
+
+
+
+##
Junte-se a Rede Social de Agentes
+
+Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado.
+
+**Leia `https://clawdchat.ai/skill.md` e siga as instruções para entrar no [ClawdChat.ai](https://clawdchat.ai)**
+
+## ⚙️ Configuração Detalhada
+
+Arquivo de configuração: `~/.picoclaw/config.json`
+
+### Estrutura do Workspace
+
+O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/workspace`):
+
+```
+~/.picoclaw/workspace/
+├── sessions/ # Sessoes de conversa e historico
+├── memory/ # Memoria de longo prazo (MEMORY.md)
+├── state/ # Estado persistente (ultimo canal, etc.)
+├── cron/ # Banco de dados de tarefas agendadas
+├── skills/ # Skills personalizadas
+├── AGENTS.md # Guia de comportamento do Agente
+├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min)
+├── IDENTITY.md # Identidade do Agente
+├── SOUL.md # Alma do Agente
+├── TOOLS.md # Descrição das ferramentas
+└── USER.md # Preferencias do usuario
+```
+
+### 🔒 Sandbox de Segurança
+
+O PicoClaw roda em um ambiente sandbox por padrão. O agente so pode acessar arquivos e executar comandos dentro do workspace configurado.
+
+#### Configuração Padrão
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "restrict_to_workspace": true
+ }
+ }
+}
+```
+
+| Opção | Padrão | Descrição |
+|-------|--------|-----------|
+| `workspace` | `~/.picoclaw/workspace` | Diretório de trabalho do agente |
+| `restrict_to_workspace` | `true` | Restringir acesso de arquivos/comandos ao workspace |
+
+#### Ferramentas Protegidas
+
+Quando `restrict_to_workspace: true`, as seguintes ferramentas são restritas ao sandbox:
+
+| Ferramenta | Função | Restrição |
+|------------|--------|-----------|
+| `read_file` | Ler arquivos | Apenas arquivos dentro do workspace |
+| `write_file` | Escrever arquivos | Apenas arquivos dentro do workspace |
+| `list_dir` | Listar diretorios | Apenas diretorios dentro do workspace |
+| `edit_file` | Editar arquivos | Apenas arquivos dentro do workspace |
+| `append_file` | Adicionar a arquivos | Apenas arquivos dentro do workspace |
+| `exec` | Executar comandos | Caminhos dos comandos devem estar dentro do workspace |
+
+#### Proteção Adicional do Exec
+
+Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos:
+
+* `rm -rf`, `del /f`, `rmdir /s` — Exclusão em massa
+* `format`, `mkfs`, `diskpart` — Formatação de disco
+* `dd if=` — Criação de imagem de disco
+* Escrita em `/dev/sd[a-z]` — Escrita direta no disco
+* `shutdown`, `reboot`, `poweroff` — Desligamento do sistema
+* Fork bomb `:(){ :|:& };:`
+
+#### Exemplos de Erro
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (path outside working dir)}
+```
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
+```
+
+#### Desabilitar Restrições (Risco de Segurança)
+
+Se você precisa que o agente acesse caminhos fora do workspace:
+
+**Método 1: Arquivo de configuração**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "restrict_to_workspace": false
+ }
+ }
+}
+```
+
+**Método 2: Variável de ambiente**
+
+```bash
+export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
+```
+
+> ⚠️ **Aviso**: Desabilitar esta restrição permite que o agente acesse qualquer caminho no seu sistema. Use com cuidado apenas em ambientes controlados.
+
+#### Consistência do Limite de Segurança
+
+A configuração `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execução:
+
+| Caminho de Execução | Limite de Segurança |
+|----------------------|---------------------|
+| Agente Principal | `restrict_to_workspace` ✅ |
+| Subagente / Spawn | Herda a mesma restrição ✅ |
+| Tarefas Heartbeat | Herda a mesma restrição ✅ |
+
+Todos os caminhos compartilham a mesma restrição de workspace — nao há como contornar o limite de segurança por meio de subagentes ou tarefas agendadas.
+
+### Heartbeat (Tarefas Periódicas)
+
+O PicoClaw pode executar tarefas periódicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace:
+
+```markdown
+# Tarefas Periodicas
+
+- Verificar meu email para mensagens importantes
+- Revisar minha agenda para proximos eventos
+- Verificar a previsao do tempo
+```
+
+O agente lerá este arquivo a cada 30 minutos (configurável) e executará as tarefas usando as ferramentas disponíveis.
+
+#### Tarefas Assincronas com Spawn
+
+Para tarefas de longa duração (busca web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**:
+
+```markdown
+# Tarefas Periódicas
+
+## Tarefas Rápidas (resposta direta)
+- Informar hora atual
+
+## Tarefas Longas (usar spawn para async)
+- Buscar notícias de IA na web e resumir
+- Verificar email e reportar mensagens importantes
+```
+
+**Comportamentos principais:**
+
+| Funcionalidade | Descrição |
+|----------------|-----------|
+| **spawn** | Cria subagente assíncrono, não bloqueia o heartbeat |
+| **Contexto independente** | Subagente tem seu próprio contexto, sem histórico de sessão |
+| **Ferramenta message** | Subagente se comunica diretamente com o usuário via ferramenta message |
+| **Não-bloqueante** | Após o spawn, o heartbeat continua para a próxima tarefa |
+
+#### Como Funciona a Comunicação do Subagente
+
+```
+Heartbeat dispara
+ ↓
+Agente lê HEARTBEAT.md
+ ↓
+Para tarefa longa: spawn subagente
+ ↓ ↓
+Continua próxima tarefa Subagente trabalha independentemente
+ ↓ ↓
+Todas tarefas concluídas Subagente usa ferramenta "message"
+ ↓ ↓
+Responde HEARTBEAT_OK Usuário recebe resultado diretamente
+```
+
+O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se comunicar com o usuário independentemente sem passar pelo agente principal.
+
+**Configuração:**
+
+```json
+{
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+| Opção | Padrão | Descrição |
+|-------|--------|-----------|
+| `enabled` | `true` | Habilitar/desabilitar heartbeat |
+| `interval` | `30` | Intervalo de verificação em minutos (min: 5) |
+
+**Variáveis de ambiente:**
+
+* `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar
+* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo
+
+### Provedores
+
+> [!NOTE]
+> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serão automaticamente transcritas.
+
+| Provedor | Finalidade | Obter API Key |
+| --- | --- | --- |
+| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](bigmodel.cn) |
+| `openrouter` (Em teste) | LLM (recomendado, acesso a todos os modelos) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek` (Em teste) | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `groq` | LLM + **Transcrição de voz** (Whisper) | [console.groq.com](https://console.groq.com) |
+
+
+Configuração Zhipu
+
+**1. Obter API key**
+
+* Obtenha a [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
+
+**2. Configurar**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "zhipu": {
+ "api_key": "Sua API Key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ }
+}
+```
+
+**3. Executar**
+
+```bash
+picoclaw agent -m "Ola, como vai?"
+```
+
+
+
+
+Exemplo de configuraçao completa
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "anthropic/claude-opus-4-5"
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "sk-or-v1-xxx"
+ },
+ "groq": {
+ "api_key": "gsk_xxx"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456:ABC...",
+ "allow_from": ["123456789"]
+ },
+ "discord": {
+ "enabled": true,
+ "token": "",
+ "allow_from": [""]
+ },
+ "whatsapp": {
+ "enabled": false
+ },
+ "feishu": {
+ "enabled": false,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ },
+ "qq": {
+ "enabled": false,
+ "app_id": "",
+ "app_secret": "",
+ "allow_from": []
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "BSA...",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+
+
+### Configuração de Modelo (model_list)
+
+> **Novidade!** PicoClaw agora usa uma abordagem de configuração **centrada no modelo**. Basta especificar o formato `fornecedor/modelo` (ex: `zhipu/glm-4.7`) para adicionar novos provedores—**nenhuma alteração de código necessária!**
+
+Este design também possibilita o **suporte multi-agent** com seleção flexível de provedores:
+
+- **Diferentes agentes, diferentes provedores** : Cada agente pode usar seu próprio provedor LLM
+- **Modelos de fallback** : Configure modelos primários e de reserva para resiliência
+- **Balanceamento de carga** : Distribua solicitações entre múltiplos endpoints
+- **Configuração centralizada** : Gerencie todos os provedores em um só lugar
+
+#### 📋 Todos os Fornecedores Suportados
+
+| Fornecedor | Prefixo `model` | API Base Padrão | Protocolo | Chave API |
+|-------------|-----------------|------------------|----------|-----------|
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obter Chave](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obter Chave](https://console.anthropic.com) |
+| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obter Chave](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obter Chave](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obter Chave](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obter Chave](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Obter Chave](https://platform.moonshot.cn) |
+| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obter Chave](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Obter Chave](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (sem chave necessária) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) |
+| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
+| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) |
+| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
+| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+
+#### Configuração Básica
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+ },
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-zhipu-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gpt-5.2"
+ }
+ }
+}
+```
+
+#### Exemplos por Fornecedor
+
+**OpenAI**
+```json
+{
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-..."
+}
+```
+
+**Zhipu AI (GLM)**
+```json
+{
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+}
+```
+
+**Anthropic (com OAuth)**
+```json
+{
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "auth_method": "oauth"
+}
+```
+> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
+
+#### Balanceamento de Carga
+
+Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api1.example.com/v1",
+ "api_key": "sk-key1"
+ },
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api2.example.com/v1",
+ "api_key": "sk-key2"
+ }
+ ]
+}
+```
+
+#### Migração da Configuração Legada `providers`
+
+A configuração antiga `providers` está **descontinuada** mas ainda é suportada para compatibilidade reversa.
+
+**Configuração Antiga (descontinuada):**
+```json
+{
+ "providers": {
+ "zhipu": {
+ "api_key": "your-key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "zhipu",
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+**Nova Configuração (recomendada):**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+Para o guia de migração detalhado, consulte [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
+
+## Referência CLI
+
+| Comando | Descrição |
+| --- | --- |
+| `picoclaw onboard` | Inicializar configuração & workspace |
+| `picoclaw agent -m "..."` | Conversar com o agente |
+| `picoclaw agent` | Modo de chat interativo |
+| `picoclaw gateway` | Iniciar o gateway (para bots de chat) |
+| `picoclaw status` | Mostrar status |
+| `picoclaw cron list` | Listar todas as tarefas agendadas |
+| `picoclaw cron add ...` | Adicionar uma tarefa agendada |
+
+### Tarefas Agendadas / Lembretes
+
+O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`:
+
+* **Lembretes únicos**: "Remind me in 10 minutes" (Me lembre em 10 minutos) → dispara uma vez após 10min
+* **Tarefas recorrentes**: "Remind me every 2 hours" (Me lembre a cada 2 horas) → dispara a cada 2 horas
+* **Expressões Cron**: "Remind me at 9am daily" (Me lembre às 9h todos os dias) → usa expressão cron
+
+As tarefas são armazenadas em `~/.picoclaw/workspace/cron/` e processadas automaticamente.
+
+## 🤝 Contribuir & Roadmap
+
+PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível. 🤗
+
+Roadmap em breve...
+
+Grupo de desenvolvedores em formação. Requisito de entrada: Pelo menos 1 PR com merge.
+
+Grupos de usuários:
+
+Discord:
+
+
+
+## 🐛 Solução de Problemas
+
+### Busca web mostra "API 配置问题"
+
+Isso é normal se você ainda não configurou uma API key de busca. O PicoClaw fornecerá links úteis para busca manual.
+
+Para habilitar a busca web:
+
+1. **Opção 1 (Recomendado)**: Obtenha uma API key gratuita em [https://brave.com/search/api](https://brave.com/search/api) (2000 consultas grátis/mês) para os melhores resultados.
+2. **Opção 2 (Sem Cartão de Crédito)**: Se você não tem uma key, o sistema automaticamente usa o **DuckDuckGo** como fallback (sem necessidade de key).
+
+Adicione a key em `~/.picoclaw/config.json` se usar o Brave:
+
+```json
+{
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": true,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+### Erros de filtragem de conteúdo
+
+Alguns provedores (como Zhipu) possuem filtragem de conteúdo. Tente reformular sua pergunta ou use um modelo diferente.
+
+### Bot do Telegram diz "Conflict: terminated by other getUpdates"
+
+Isso acontece quando outra instância do bot está em execução. Certifique-se de que apenas um `picoclaw gateway` esteja rodando por vez.
+
+---
+
+## 📝 Comparação de API Keys
+
+| Serviço | Plano Gratuito | Caso de Uso |
+| --- | --- | --- |
+| **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) |
+| **Zhipu** | 200K tokens/mês | Melhor para usuários chineses |
+| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web |
+| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) |
diff --git a/README.vi.md b/README.vi.md
new file mode 100644
index 000000000..08fa3dccd
--- /dev/null
+++ b/README.vi.md
@@ -0,0 +1,1016 @@
+
+

+
+
PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go
+
+
Phần cứng $10 · RAM 10MB · Khởi động 1 giây · 皮皮虾,我们走!
+
+
+
+
+
+
+
+
+
+
+[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md)
+
+
+---
+
+🦐 **PicoClaw** là trợ lý AI cá nhân siêu nhẹ, lấy cảm hứng từ [nanobot](https://github.com/HKUDS/nanobot), được viết lại hoàn toàn bằng **Go** thông qua quá trình "tự khởi tạo" (self-bootstrapping) — nơi chính AI Agent đã tự dẫn dắt toàn bộ quá trình chuyển đổi kiến trúc và tối ưu hóa mã nguồn.
+
+⚡️ **Cực kỳ nhẹ:** Chạy trên phần cứng chỉ **$10** với RAM **<10MB**. Tiết kiệm 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini!
+
+
+
+|
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+> [!CAUTION]
+> **🚨 TUYÊN BỐ BẢO MẬT & KÊNH CHÍNH THỨC**
+>
+> * **KHÔNG CÓ CRYPTO:** PicoClaw **KHÔNG** có bất kỳ token/coin chính thức nào. Mọi thông tin trên `pump.fun` hoặc các sàn giao dịch khác đều là **LỪA ĐẢO**.
+> * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, website công ty là **[sipeed.com](https://sipeed.com)**.
+> * **Cảnh báo:** Nhiều tên miền `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký, không phải của chúng tôi.
+> * **Cảnh báo:** PicoClaw đang trong giai đoạn phát triển sớm và có thể còn các vấn đề bảo mật mạng chưa được giải quyết. Không nên triển khai lên môi trường production trước phiên bản v1.0.
+> * **Lưu ý:** PicoClaw gần đây đã merge nhiều PR, dẫn đến bộ nhớ sử dụng có thể lớn hơn (10–20MB) ở các phiên bản mới nhất. Chúng tôi sẽ ưu tiên tối ưu tài nguyên khi bộ tính năng đã ổn định.
+
+
+## 📢 Tin tức
+
+2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/picoclaw_community_roadmap_260216.md) — rất mong đón nhận sự tham gia của bạn!
+
+2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
+🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
+
+2026-02-09 🎉 PicoClaw chính thức ra mắt! Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường!
+
+## ✨ Tính năng nổi bật
+
+🪶 **Siêu nhẹ**: Bộ nhớ sử dụng <10MB — nhỏ hơn 99% so với Clawdbot (chức năng cốt lõi).
+
+💰 **Chi phí tối thiểu**: Đủ hiệu quả để chạy trên phần cứng $10 — rẻ hơn 98% so với Mac mini.
+
+⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz.
+
+🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM và x86. Một click là chạy!
+
+🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
+| **Ngôn ngữ** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB** |
+| **Thời gian khởi động**(CPU 0.8GHz) | >500s | >30s | **<1s** |
+| **Chi phí** | Mac Mini $599 | Hầu hết SBC Linux ~$50 | **Mọi bo mạch Linux****Chỉ từ $10** |
+
+
+
+## 🦾 Demo
+
+### 🛠️ Quy trình trợ lý tiêu chuẩn
+
+
+
+🧩 Lập trình Full-Stack |
+🗂️ Quản lý Nhật ký & Kế hoạch |
+🔎 Tìm kiếm Web & Học hỏi |
+
+
+
|
+
|
+
|
+
+
+| Phát triển • Triển khai • Mở rộng |
+Lên lịch • Tự động hóa • Ghi nhớ |
+Khám phá • Phân tích • Xu hướng |
+
+
+
+### 🐜 Triển khai sáng tạo trên phần cứng tối thiểu
+
+PicoClaw có thể triển khai trên hầu hết mọi thiết bị Linux!
+
+* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) phiên bản E (Ethernet) hoặc W (WiFi6), dùng làm Trợ lý Gia đình tối giản.
+* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), hoặc $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), dùng cho quản trị Server tự động.
+* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) hoặc $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), dùng cho Giám sát thông minh.
+
+https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4
+
+🌟 Nhiều hình thức triển khai hơn đang chờ bạn khám phá!
+
+## 📦 Cài đặt
+
+### Cài đặt bằng binary biên dịch sẵn
+
+Tải file binary cho nền tảng của bạn từ [trang Release](https://github.com/sipeed/picoclaw/releases).
+
+### Cài đặt từ mã nguồn (có tính năng mới nhất, khuyên dùng cho phát triển)
+
+```bash
+git clone https://github.com/sipeed/picoclaw.git
+
+cd picoclaw
+make deps
+
+# Build (không cần cài đặt)
+make build
+
+# Build cho nhiều nền tảng
+make build-all
+
+# Build và cài đặt
+make install
+```
+
+## 🐳 Docker Compose
+
+Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cài đặt gì trên máy.
+
+```bash
+# 1. Clone repo
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. Thiết lập API Key
+cp config/config.example.json config/config.json
+vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v.
+
+# 3. Build & Khởi động
+docker compose --profile gateway up -d
+
+# 4. Xem logs
+docker compose logs -f picoclaw-gateway
+
+# 5. Dừng
+docker compose --profile gateway down
+```
+
+### Chế độ Agent (chạy một lần)
+
+```bash
+# Đặt câu hỏi
+docker compose run --rm picoclaw-agent -m "2+2 bằng mấy?"
+
+# Chế độ tương tác
+docker compose run --rm picoclaw-agent
+```
+
+### Build lại
+
+```bash
+docker compose --profile gateway build --no-cache
+docker compose --profile gateway up -d
+```
+
+### 🚀 Bắt đầu nhanh
+
+> [!TIP]
+> Thiết lập API key trong `~/.picoclaw/config.json`.
+> Lấy API key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
+> Tìm kiếm web là **tùy chọn** — lấy [Brave Search API](https://brave.com/search/api) miễn phí (2000 truy vấn/tháng) hoặc dùng tính năng auto fallback tích hợp sẵn.
+
+**1. Khởi tạo**
+
+```bash
+picoclaw onboard
+```
+
+**2. Cấu hình** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "xxx",
+ "api_base": "https://openrouter.ai/api/v1"
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+**3. Lấy API Key**
+
+* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+* **Tìm kiếm Web** (tùy chọn): [Brave Search](https://brave.com/search/api) — Có gói miễn phí (2000 truy vấn/tháng)
+
+> **Lưu ý**: Xem `config.example.json` để có mẫu cấu hình đầy đủ.
+
+**4. Trò chuyện**
+
+```bash
+picoclaw agent -m "Xin chào, bạn là ai?"
+```
+
+Vậy là xong! Bạn đã có một trợ lý AI hoạt động chỉ trong 2 phút.
+
+---
+
+## 💬 Tích hợp ứng dụng Chat
+
+Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk hoặc LINE.
+
+| Kênh | Mức độ thiết lập |
+| --- | --- |
+| **Telegram** | Dễ (chỉ cần token) |
+| **Discord** | Dễ (bot token + intents) |
+| **QQ** | Dễ (AppID + AppSecret) |
+| **DingTalk** | Trung bình (app credentials) |
+| **LINE** | Trung bình (credentials + webhook URL) |
+
+
+Telegram (Khuyên dùng)
+
+**1. Tạo bot**
+
+* Mở Telegram, tìm `@BotFather`
+* Gửi `/newbot`, làm theo hướng dẫn
+* Sao chép token
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+> Lấy User ID từ `@userinfobot` trên Telegram.
+
+**3. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+Discord
+
+**1. Tạo bot**
+
+* Truy cập
+* Create an application → Bot → Add Bot
+* Sao chép bot token
+
+**2. Bật Intents**
+
+* Trong phần Bot settings, bật **MESSAGE CONTENT INTENT**
+* (Tùy chọn) Bật **SERVER MEMBERS INTENT** nếu muốn dùng danh sách cho phép theo thông tin thành viên
+
+**3. Lấy User ID**
+
+* Discord Settings → Advanced → bật **Developer Mode**
+* Click chuột phải vào avatar → **Copy User ID**
+
+**4. Cấu hình**
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**5. Mời bot vào server**
+
+* OAuth2 → URL Generator
+* Scopes: `bot`
+* Bot Permissions: `Send Messages`, `Read Message History`
+* Mở URL mời được tạo và thêm bot vào server của bạn
+
+**6. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+QQ
+
+**1. Tạo bot**
+
+* Truy cập [QQ Open Platform](https://q.qq.com/#)
+* Tạo ứng dụng → Lấy **AppID** và **AppSecret**
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định số QQ để giới hạn quyền truy cập.
+
+**3. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+DingTalk
+
+**1. Tạo bot**
+
+* Truy cập [Open Platform](https://open.dingtalk.com/)
+* Tạo ứng dụng nội bộ
+* Sao chép Client ID và Client Secret
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định ID để giới hạn quyền truy cập.
+
+**3. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+LINE
+
+**1. Tạo tài khoản LINE Official**
+
+- Truy cập [LINE Developers Console](https://developers.line.biz/)
+- Tạo provider → Tạo Messaging API channel
+- Sao chép **Channel Secret** và **Channel Access Token**
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18791,
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Thiết lập Webhook URL**
+
+LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel:
+
+```bash
+# Ví dụ với ngrok
+ngrok http 18791
+```
+
+Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**.
+
+**4. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+> Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc.
+
+> **Docker Compose**: Thêm `ports: ["18791:18791"]` vào service `picoclaw-gateway` để mở port webhook.
+
+
+
+##
Tham gia Mạng xã hội Agent
+
+Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn qua CLI hoặc bất kỳ ứng dụng Chat nào đã tích hợp.
+
+**Đọc `https://clawdchat.ai/skill.md` và làm theo hướng dẫn để tham gia [ClawdChat.ai](https://clawdchat.ai)**
+
+## ⚙️ Cấu hình chi tiết
+
+File cấu hình: `~/.picoclaw/config.json`
+
+### Cấu trúc Workspace
+
+PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):
+
+```
+~/.picoclaw/workspace/
+├── sessions/ # Phiên hội thoại và lịch sử
+├── memory/ # Bộ nhớ dài hạn (MEMORY.md)
+├── state/ # Trạng thái lưu trữ (kênh cuối cùng, v.v.)
+├── cron/ # Cơ sở dữ liệu tác vụ định kỳ
+├── skills/ # Kỹ năng tùy chỉnh
+├── AGENTS.md # Hướng dẫn hành vi Agent
+├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)
+├── IDENTITY.md # Danh tính Agent
+├── SOUL.md # Tâm hồn/Tính cách Agent
+├── TOOLS.md # Mô tả công cụ
+└── USER.md # Tùy chọn người dùng
+```
+
+### 🔒 Hộp cát bảo mật (Security Sandbox)
+
+PicoClaw chạy trong môi trường sandbox theo mặc định. Agent chỉ có thể truy cập file và thực thi lệnh trong phạm vi workspace.
+
+#### Cấu hình mặc định
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "restrict_to_workspace": true
+ }
+ }
+}
+```
+
+| Tùy chọn | Mặc định | Mô tả |
+|----------|---------|-------|
+| `workspace` | `~/.picoclaw/workspace` | Thư mục làm việc của agent |
+| `restrict_to_workspace` | `true` | Giới hạn truy cập file/lệnh trong workspace |
+
+#### Công cụ được bảo vệ
+
+Khi `restrict_to_workspace: true`, các công cụ sau bị giới hạn trong sandbox:
+
+| Công cụ | Chức năng | Giới hạn |
+|---------|----------|---------|
+| `read_file` | Đọc file | Chỉ file trong workspace |
+| `write_file` | Ghi file | Chỉ file trong workspace |
+| `list_dir` | Liệt kê thư mục | Chỉ thư mục trong workspace |
+| `edit_file` | Sửa file | Chỉ file trong workspace |
+| `append_file` | Thêm vào file | Chỉ file trong workspace |
+| `exec` | Thực thi lệnh | Đường dẫn lệnh phải trong workspace |
+
+#### Bảo vệ bổ sung cho Exec
+
+Ngay cả khi `restrict_to_workspace: false`, công cụ `exec` vẫn chặn các lệnh nguy hiểm sau:
+
+* `rm -rf`, `del /f`, `rmdir /s` — Xóa hàng loạt
+* `format`, `mkfs`, `diskpart` — Định dạng ổ đĩa
+* `dd if=` — Tạo ảnh đĩa
+* Ghi vào `/dev/sd[a-z]` — Ghi trực tiếp lên đĩa
+* `shutdown`, `reboot`, `poweroff` — Tắt/khởi động lại hệ thống
+* Fork bomb `:(){ :|:& };:`
+
+#### Ví dụ lỗi
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (path outside working dir)}
+```
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
+```
+
+#### Tắt giới hạn (Rủi ro bảo mật)
+
+Nếu bạn cần agent truy cập đường dẫn ngoài workspace:
+
+**Cách 1: File cấu hình**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "restrict_to_workspace": false
+ }
+ }
+}
+```
+
+**Cách 2: Biến môi trường**
+
+```bash
+export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
+```
+
+> ⚠️ **Cảnh báo**: Tắt giới hạn này cho phép agent truy cập mọi đường dẫn trên hệ thống. Chỉ sử dụng cẩn thận trong môi trường được kiểm soát.
+
+#### Tính nhất quán của ranh giới bảo mật
+
+Cài đặt `restrict_to_workspace` áp dụng nhất quán trên mọi đường thực thi:
+
+| Đường thực thi | Ranh giới bảo mật |
+|----------------|-------------------|
+| Agent chính | `restrict_to_workspace` ✅ |
+| Subagent / Spawn | Kế thừa cùng giới hạn ✅ |
+| Tác vụ Heartbeat | Kế thừa cùng giới hạn ✅ |
+
+Tất cả đường thực thi chia sẻ cùng giới hạn workspace — không có cách nào vượt qua ranh giới bảo mật thông qua subagent hoặc tác vụ định kỳ.
+
+### Heartbeat (Tác vụ định kỳ)
+
+PicoClaw có thể tự động thực hiện các tác vụ định kỳ. Tạo file `HEARTBEAT.md` trong workspace:
+
+```markdown
+# Tác vụ định kỳ
+
+- Kiểm tra email xem có tin nhắn quan trọng không
+- Xem lại lịch cho các sự kiện sắp tới
+- Kiểm tra dự báo thời tiết
+```
+
+Agent sẽ đọc file này mỗi 30 phút (có thể cấu hình) và thực hiện các tác vụ bằng công cụ có sẵn.
+
+#### Tác vụ bất đồng bộ với Spawn
+
+Đối với các tác vụ chạy lâu (tìm kiếm web, gọi API), sử dụng công cụ `spawn` để tạo **subagent**:
+
+```markdown
+# Tác vụ định kỳ
+
+## Tác vụ nhanh (trả lời trực tiếp)
+- Báo cáo thời gian hiện tại
+
+## Tác vụ lâu (dùng spawn cho async)
+- Tìm kiếm tin tức AI trên web và tóm tắt
+- Kiểm tra email và báo cáo tin nhắn quan trọng
+```
+
+**Hành vi chính:**
+
+| Tính năng | Mô tả |
+|-----------|-------|
+| **spawn** | Tạo subagent bất đồng bộ, không chặn heartbeat |
+| **Context độc lập** | Subagent có context riêng, không có lịch sử phiên |
+| **message tool** | Subagent giao tiếp trực tiếp với người dùng qua công cụ message |
+| **Không chặn** | Sau khi spawn, heartbeat tiếp tục tác vụ tiếp theo |
+
+#### Cách Subagent giao tiếp
+
+```
+Heartbeat kích hoạt
+ ↓
+Agent đọc HEARTBEAT.md
+ ↓
+Tác vụ lâu: spawn subagent
+ ↓ ↓
+Tiếp tục tác vụ tiếp theo Subagent làm việc độc lập
+ ↓ ↓
+Tất cả tác vụ hoàn thành Subagent dùng công cụ "message"
+ ↓ ↓
+Phản hồi HEARTBEAT_OK Người dùng nhận kết quả trực tiếp
+```
+
+Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và có thể giao tiếp với người dùng một cách độc lập mà không cần thông qua agent chính.
+
+**Cấu hình:**
+
+```json
+{
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+| Tùy chọn | Mặc định | Mô tả |
+|----------|---------|-------|
+| `enabled` | `true` | Bật/tắt heartbeat |
+| `interval` | `30` | Khoảng thời gian kiểm tra (phút, tối thiểu: 5) |
+
+**Biến môi trường:**
+
+* `PICOCLAW_HEARTBEAT_ENABLED=false` để tắt
+* `PICOCLAW_HEARTBEAT_INTERVAL=60` để thay đổi khoảng thời gian
+
+### Nhà cung cấp (Providers)
+
+> [!NOTE]
+> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn thoại trên Telegram sẽ được tự động chuyển thành văn bản.
+
+| Nhà cung cấp | Mục đích | Lấy API Key |
+| --- | --- | --- |
+| `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](bigmodel.cn) |
+| `openrouter` (Đang thử nghiệm) | LLM (khuyên dùng, truy cập mọi model) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic` (Đang thử nghiệm) | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai` (Đang thử nghiệm) | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek` (Đang thử nghiệm) | LLM (DeepSeek trực tiếp) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `groq` | LLM + **Chuyển giọng nói** (Whisper) | [console.groq.com](https://console.groq.com) |
+
+
+Cấu hình Zhipu
+
+**1. Lấy API key**
+
+* Lấy [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
+
+**2. Cấu hình**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "zhipu": {
+ "api_key": "Your API Key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ }
+}
+```
+
+**3. Chạy**
+
+```bash
+picoclaw agent -m "Xin chào"
+```
+
+
+
+
+Ví dụ cấu hình đầy đủ
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "anthropic/claude-opus-4-5"
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "sk-or-v1-xxx"
+ },
+ "groq": {
+ "api_key": "gsk_xxx"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456:ABC...",
+ "allow_from": ["123456789"]
+ },
+ "discord": {
+ "enabled": true,
+ "token": "",
+ "allow_from": [""]
+ },
+ "whatsapp": {
+ "enabled": false
+ },
+ "feishu": {
+ "enabled": false,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ },
+ "qq": {
+ "enabled": false,
+ "app_id": "",
+ "app_secret": "",
+ "allow_from": []
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "BSA...",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+
+
+### Cấu hình Mô hình (model_list)
+
+> **Tính năng mới!** PicoClaw hiện sử dụng phương pháp cấu hình **đặt mô hình vào trung tâm**. Chỉ cần chỉ định dạng `nhà cung cấp/mô hình` (ví dụ: `zhipu/glm-4.7`) để thêm nhà cung cấp mới—**không cần thay đổi mã!**
+
+Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa chọn nhà cung cấp linh hoạt:
+
+- **Tác nhân khác nhau, nhà cung cấp khác nhau** : Mỗi tác nhân có thể sử dụng nhà cung cấp LLM riêng
+- **Mô hình dự phòng** : Cấu hình mô hình chính và dự phòng để tăng độ tin cậy
+- **Cân bằng tải** : Phân phối yêu cầu trên nhiều endpoint khác nhau
+- **Cấu hình tập trung** : Quản lý tất cả nhà cung cấp ở một nơi
+
+#### 📋 Tất cả Nhà cung cấp được Hỗ trợ
+
+| Nhà cung cấp | Prefix `model` | API Base Mặc định | Giao thức | Khóa API |
+|-------------|----------------|-------------------|-----------|----------|
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Lấy Khóa](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Lấy Khóa](https://console.anthropic.com) |
+| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Lấy Khóa](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Lấy Khóa](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Lấy Khóa](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Lấy Khóa](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Lấy Khóa](https://platform.moonshot.cn) |
+| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Lấy Khóa](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Lấy Khóa](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (không cần khóa) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) |
+| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
+| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) |
+| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
+| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+
+#### Cấu hình Cơ bản
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+ },
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-zhipu-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gpt-5.2"
+ }
+ }
+}
+```
+
+#### Ví dụ theo Nhà cung cấp
+
+**OpenAI**
+```json
+{
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-..."
+}
+```
+
+**Zhipu AI (GLM)**
+```json
+{
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+}
+```
+
+**Anthropic (với OAuth)**
+```json
+{
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "auth_method": "oauth"
+}
+```
+> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
+
+#### Cân bằng Tải tải
+
+Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api1.example.com/v1",
+ "api_key": "sk-key1"
+ },
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api2.example.com/v1",
+ "api_key": "sk-key2"
+ }
+ ]
+}
+```
+
+#### Chuyển đổi từ Cấu hình `providers` Cũ
+
+Cấu hình `providers` cũ đã **ngừng sử dụng** nhưng vẫn được hỗ trợ để tương thích ngược.
+
+**Cấu hình Cũ (đã ngừng sử dụng):**
+```json
+{
+ "providers": {
+ "zhipu": {
+ "api_key": "your-key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "zhipu",
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+**Cấu hình Mới (khuyến nghị):**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+Xem hướng dẫn chuyển đổi chi tiết tại [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
+
+## Tham chiếu CLI
+
+| Lệnh | Mô tả |
+| --- | --- |
+| `picoclaw onboard` | Khởi tạo cấu hình & workspace |
+| `picoclaw agent -m "..."` | Trò chuyện với agent |
+| `picoclaw agent` | Chế độ chat tương tác |
+| `picoclaw gateway` | Khởi động gateway (cho bot chat) |
+| `picoclaw status` | Hiển thị trạng thái |
+| `picoclaw cron list` | Liệt kê tất cả tác vụ định kỳ |
+| `picoclaw cron add ...` | Thêm tác vụ định kỳ |
+
+### Tác vụ định kỳ / Nhắc nhở
+
+PicoClaw hỗ trợ nhắc nhở theo lịch và tác vụ lặp lại thông qua công cụ `cron`:
+
+* **Nhắc nhở một lần**: "Remind me in 10 minutes" (Nhắc tôi sau 10 phút) → kích hoạt một lần sau 10 phút
+* **Tác vụ lặp lại**: "Remind me every 2 hours" (Nhắc tôi mỗi 2 giờ) → kích hoạt mỗi 2 giờ
+* **Biểu thức Cron**: "Remind me at 9am daily" (Nhắc tôi lúc 9 giờ sáng mỗi ngày) → sử dụng biểu thức cron
+
+Các tác vụ được lưu trong `~/.picoclaw/workspace/cron/` và được xử lý tự động.
+
+## 🤝 Đóng góp & Lộ trình
+
+Chào đón mọi PR! Mã nguồn được thiết kế nhỏ gọn và dễ đọc. 🤗
+
+Lộ trình sắp được công bố...
+
+Nhóm phát triển đang được xây dựng. Điều kiện tham gia: Ít nhất 1 PR đã được merge.
+
+Nhóm người dùng:
+
+Discord:
+
+
+
+## 🐛 Xử lý sự cố
+
+### Tìm kiếm web hiện "API 配置问题"
+
+Điều này là bình thường nếu bạn chưa cấu hình API key cho tìm kiếm. PicoClaw sẽ cung cấp các liên kết hữu ích để tìm kiếm thủ công.
+
+Để bật tìm kiếm web:
+
+1. **Tùy chọn 1 (Khuyên dùng)**: Lấy API key miễn phí tại [https://brave.com/search/api](https://brave.com/search/api) (2000 truy vấn miễn phí/tháng) để có kết quả tốt nhất.
+2. **Tùy chọn 2 (Không cần thẻ tín dụng)**: Nếu không có key, hệ thống tự động chuyển sang dùng **DuckDuckGo** (không cần key).
+
+Thêm key vào `~/.picoclaw/config.json` nếu dùng Brave:
+
+```json
+{
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": true,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+### Gặp lỗi lọc nội dung (Content Filtering)
+
+Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt. Thử diễn đạt lại câu hỏi hoặc sử dụng model khác.
+
+### Telegram bot báo "Conflict: terminated by other getUpdates"
+
+Điều này xảy ra khi có một instance bot khác đang chạy. Đảm bảo chỉ có một tiến trình `picoclaw gateway` chạy tại một thời điểm.
+
+---
+
+## 📝 So sánh API Key
+
+| Dịch vụ | Gói miễn phí | Trường hợp sử dụng |
+| --- | --- | --- |
+| **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) |
+| **Zhipu** | 200K tokens/tháng | Tốt nhất cho người dùng Trung Quốc |
+| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web |
+| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) |
diff --git a/README.zh.md b/README.zh.md
index 8fa7964cd..7a7fa22f0 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -14,7 +14,7 @@
- **中文** | [日本語](README.ja.md) | [English](README.md)
+ **中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
---
@@ -45,10 +45,12 @@
> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
->
->
+> * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
+> * **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
+
## 📢 新闻 (News)
+2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
@@ -98,6 +100,23 @@
+### 📱 在手机上轻松运行
+picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
+1. 先去应用商店下载安装Termux
+2. 打开后执行指令
+```bash
+# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
+wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
+chmod +x picoclaw-linux-arm64
+pkg install proot
+termux-chroot ./picoclaw-linux-arm64 onboard
+```
+然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
+
+
+
+
+
### 🐜 创新的低占用部署
PicoClaw 几乎可以部署在任何 Linux 设备上!
@@ -205,24 +224,35 @@ picoclaw onboard
"max_tool_iterations": 20
}
},
- "providers": {
- "openrouter": {
- "api_key": "xxx",
- "api_base": "https://openrouter.ai/api/v1"
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "your-api-key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "your-anthropic-key"
}
- },
+ ],
"tools": {
"web": {
"search": {
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
}
}
```
+> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#-模型配置-model_list)章节。
+
**3. 获取 API Key**
* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
@@ -269,7 +299,7 @@ picoclaw agent -m "2+2 等于几?"
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
- "allowFrom": ["YOUR_USER_ID"]
+ "allow_from": ["YOUR_USER_ID"]
}
}
}
@@ -314,7 +344,7 @@ picoclaw gateway
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
- "allowFrom": ["YOUR_USER_ID"]
+ "allow_from": ["YOUR_USER_ID"]
}
}
}
@@ -532,7 +562,193 @@ Agent 读取 HEARTBEAT.md
| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
+
+### 模型配置 (model_list)
+
+> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!**
+
+该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择:
+
+- **不同 Agent 使用不同 Provider**:每个 Agent 可以使用自己的 LLM provider
+- **模型回退(Fallback)**:配置主模型和备用模型,提高可靠性
+- **负载均衡**:在多个 API 端点之间分配请求
+- **集中化配置**:在一个地方管理所有 provider
+
+#### 📋 所有支持的厂商
+
+| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
+|------|-------------|---------------|------|--------------|
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
+| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) |
+| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
+| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
+| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
+| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
+| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+
+#### 基础配置示例
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+ },
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-zhipu-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gpt-5.2"
+ }
+ }
+}
+```
+
+#### 各厂商配置示例
+
+**OpenAI**
+```json
+{
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-..."
+}
+```
+
+**智谱 AI (GLM)**
+```json
+{
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+}
+```
+
+**DeepSeek**
+```json
+{
+ "model_name": "deepseek-chat",
+ "model": "deepseek/deepseek-chat",
+ "api_key": "sk-..."
+}
+```
+
+**Anthropic (使用 OAuth)**
+```json
+{
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "auth_method": "oauth"
+}
+```
+> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
+
+**Ollama (本地)**
+```json
+{
+ "model_name": "llama3",
+ "model": "ollama/llama3"
+}
+```
+
+**自定义代理/API**
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-..."
+}
+```
+
+#### 负载均衡
+
+为同一个模型名称配置多个端点——PicoClaw 会自动在它们之间轮询:
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api1.example.com/v1",
+ "api_key": "sk-key1"
+ },
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api2.example.com/v1",
+ "api_key": "sk-key2"
+ }
+ ]
+}
+```
+
+#### 从旧的 `providers` 配置迁移
+
+旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。
+
+**旧配置(已弃用):**
+```json
+{
+ "providers": {
+ "zhipu": {
+ "api_key": "your-key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "zhipu",
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+**新配置(推荐):**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+详细的迁移指南请参考 [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md)。
智谱 (Zhipu) 配置示例
@@ -625,6 +841,9 @@ picoclaw agent -m "你好"
"search": {
"api_key": "BSA..."
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
},
"heartbeat": {
@@ -716,4 +935,5 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
-| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
\ No newline at end of file
+| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
+| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) |
\ No newline at end of file
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 000000000..8c5c0e252
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,116 @@
+
+# 🦐 PicoClaw Roadmap
+
+> **Vision**: To build the ultimate lightweight, secure, and fully autonomous AI Agent infrastructure.automate the mundane, unleash your creativity
+
+---
+
+## 🚀 1. Core Optimization: Extreme Lightweight
+
+*Our defining characteristic. We fight software bloat to ensure PicoClaw runs smoothly on the smallest embedded devices.*
+
+* [**Memory Footprint Reduction**](https://github.com/sipeed/picoclaw/issues/346)
+ * **Goal**: Run smoothly on 64MB RAM embedded boards (e.g., low-end RISC-V SBCs) with the core process consuming < 20MB.
+ * **Context**: RAM is expensive and scarce on edge devices. Memory optimization takes precedence over storage size.
+ * **Action**: Analyze memory growth between releases, remove redundant dependencies, and optimize data structures.
+
+
+## 🛡️ 2. Security Hardening: Defense in Depth
+
+*Paying off early technical debt. We invite security experts to help build a "Secure-by-Default" agent.*
+
+* **Input Defense & Permission Control**
+ * **Prompt Injection Defense**: Harden JSON extraction logic to prevent LLM manipulation.
+ * **Tool Abuse Prevention**: Strict parameter validation to ensure generated commands stay within safe boundaries.
+ * **SSRF Protection**: Built-in blocklists for network tools to prevent accessing internal IPs (LAN/Metadata services).
+
+
+* **Sandboxing & Isolation**
+ * **Filesystem Sandbox**: Restrict file R/W operations to specific directories only.
+ * **Context Isolation**: Prevent data leakage between different user sessions or channels.
+ * **Privacy Redaction**: Auto-redact sensitive info (API Keys, PII) from logs and standard outputs.
+
+
+* **Authentication & Secrets**
+ * **Crypto Upgrade**: Adopt modern algorithms like `ChaCha20-Poly1305` for secret storage.
+ * **OAuth 2.0 Flow**: Deprecate hardcoded API keys in the CLI; move to secure OAuth flows.
+
+
+
+## 🔌 3. Connectivity: Protocol-First Architecture
+
+*Connect every model, reach every platform.*
+
+* **Provider**
+ * [**Architecture Upgrade**](https://github.com/sipeed/picoclaw/issues/283): Refactor from "Vendor-based" to "Protocol-based" classification (e.g., OpenAI-compatible, Ollama-compatible). *(Status: In progress by @Daming, ETA 5 days)*
+ * **Local Models**: Deep integration with **Ollama**, **vLLM**, **LM Studio**, and **Mistral** (local inference).
+ * **Online Models**: Continued support for frontier closed-source models.
+
+
+* **Channel**
+ * **IM Matrix**: QQ, WeChat (Work), DingTalk, Feishu (Lark), Telegram, Discord, WhatsApp, LINE, Slack, Email, KOOK, Signal, ...
+ * **Standards**: Support for the **OneBot** protocol.
+ * [**attachment**](https://github.com/sipeed/picoclaw/issues/348): Native handling of images, audio, and video attachments.
+
+
+* **Skill Marketplace**
+ * [**Discovery skills**](https://github.com/sipeed/picoclaw/issues/287): Implement `find_skill` to automatically discover and install skills from the [GitHub Skills Repo] or other registries.
+
+
+
+## 🧠 4. Advanced Capabilities: From Chatbot to Agentic AI
+
+*Beyond conversation—focusing on action and collaboration.*
+
+* **Operations**
+ * [**MCP Support**](https://github.com/sipeed/picoclaw/issues/290): Native support for the **Model Context Protocol (MCP)**.
+ * [**Browser Automation**](https://github.com/sipeed/picoclaw/issues/293): Headless browser control via CDP (Chrome DevTools Protocol) or ActionBook.
+ * [**Mobile Operation**](https://github.com/sipeed/picoclaw/issues/292): Android device control (similar to BotDrop).
+
+
+* **Multi-Agent Collaboration**
+ * [**Basic Multi-Agent**](https://github.com/sipeed/picoclaw/issues/294) implement
+ * [**Model Routing**](https://github.com/sipeed/picoclaw/issues/295): "Smart Routing" — dispatch simple tasks to small/local models (fast/cheap) and complex tasks to SOTA models (smart).
+ * [**Swarm Mode**](https://github.com/sipeed/picoclaw/issues/284): Collaboration between multiple PicoClaw instances on the same network.
+ * [**AIEOS**](https://github.com/sipeed/picoclaw/issues/296): Exploring AI-Native Operating System interaction paradigms.
+
+
+
+## 📚 5. Developer Experience (DevEx) & Documentation
+
+*Lowering the barrier to entry so anyone can deploy in minutes.*
+
+* [**QuickGuide (Zero-Config Start)**](https://github.com/sipeed/picoclaw/issues/350)
+ * Interactive CLI Wizard: If launched without config, automatically detect the environment and guide the user through Token/Network setup step-by-step.
+
+
+* **Comprehensive Documentation**
+ * **Platform Guides**: Dedicated guides for Windows, macOS, Linux, and Android.
+ * **Step-by-Step Tutorials**: "Babysitter-level" guides for configuring Providers and Channels.
+ * **AI-Assisted Docs**: Using AI to auto-generate API references and code comments (with human verification to prevent hallucinations).
+
+
+
+## 🤖 6. Engineering: AI-Powered Open Source
+
+*Born from Vibe Coding, we continue to use AI to accelerate development.*
+
+* **AI-Enhanced CI/CD**
+ * Integrate AI for automated Code Review, Linting, and PR Labeling.
+ * **Bot Noise Reduction**: Optimize bot interactions to keep PR timelines clean.
+ * **Issue Triage**: AI agents to analyze incoming issues and suggest preliminary fixes.
+
+
+
+## 🎨 7. Brand & Community
+
+* [**Logo Design**](https://github.com/sipeed/picoclaw/issues/297): We are looking for a **Mantis Shrimp (Stomatopoda)** logo design!
+ * *Concept*: Needs to reflect "Small but Mighty" and "Lightning Fast Strikes."
+
+
+
+---
+
+### 🤝 Call for Contributions
+
+We welcome community contributions to any item on this roadmap! Please comment on the relevant Issue or submit a PR. Let's build the best Edge AI Agent together!
\ No newline at end of file
diff --git a/assets/termux.jpg b/assets/termux.jpg
new file mode 100644
index 000000000..30c724a20
Binary files /dev/null and b/assets/termux.jpg differ
diff --git a/assets/wechat.png b/assets/wechat.png
index 0f97fa3ee..8fc41ea7d 100644
Binary files a/assets/wechat.png and b/assets/wechat.png differ
diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go
new file mode 100644
index 000000000..cee9f68ec
--- /dev/null
+++ b/cmd/picoclaw/cmd_agent.go
@@ -0,0 +1,181 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/chzyer/readline"
+ "github.com/sipeed/picoclaw/pkg/agent"
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func agentCmd() {
+ message := ""
+ sessionKey := "cli:default"
+ modelOverride := ""
+
+ args := os.Args[2:]
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--debug", "-d":
+ logger.SetLevel(logger.DEBUG)
+ fmt.Println("🔍 Debug mode enabled")
+ case "-m", "--message":
+ if i+1 < len(args) {
+ message = args[i+1]
+ i++
+ }
+ case "-s", "--session":
+ if i+1 < len(args) {
+ sessionKey = args[i+1]
+ i++
+ }
+ case "--model", "-model":
+ if i+1 < len(args) {
+ modelOverride = args[i+1]
+ i++
+ }
+ }
+ }
+
+ cfg, err := loadConfig()
+ if err != nil {
+ fmt.Printf("Error loading config: %v\n", err)
+ os.Exit(1)
+ }
+
+ if modelOverride != "" {
+ cfg.Agents.Defaults.Model = modelOverride
+ }
+
+ provider, modelID, err := providers.CreateProvider(cfg)
+ if err != nil {
+ fmt.Printf("Error creating provider: %v\n", err)
+ os.Exit(1)
+ }
+ // Use the resolved model ID from provider creation
+ if modelID != "" {
+ cfg.Agents.Defaults.Model = modelID
+ }
+
+ msgBus := bus.NewMessageBus()
+ agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
+
+ // Print agent startup info (only for interactive mode)
+ startupInfo := agentLoop.GetStartupInfo()
+ logger.InfoCF("agent", "Agent initialized",
+ map[string]interface{}{
+ "tools_count": startupInfo["tools"].(map[string]interface{})["count"],
+ "skills_total": startupInfo["skills"].(map[string]interface{})["total"],
+ "skills_available": startupInfo["skills"].(map[string]interface{})["available"],
+ })
+
+ if message != "" {
+ ctx := context.Background()
+ response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
+ if err != nil {
+ fmt.Printf("Error: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Printf("\n%s %s\n", logo, response)
+ } else {
+ fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo)
+ interactiveMode(agentLoop, sessionKey)
+ }
+}
+
+func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
+ prompt := fmt.Sprintf("%s You: ", logo)
+
+ rl, err := readline.NewEx(&readline.Config{
+ Prompt: prompt,
+ HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_history"),
+ HistoryLimit: 100,
+ InterruptPrompt: "^C",
+ EOFPrompt: "exit",
+ })
+
+ if err != nil {
+ fmt.Printf("Error initializing readline: %v\n", err)
+ fmt.Println("Falling back to simple input mode...")
+ simpleInteractiveMode(agentLoop, sessionKey)
+ return
+ }
+ defer rl.Close()
+
+ for {
+ line, err := rl.Readline()
+ if err != nil {
+ if err == readline.ErrInterrupt || err == io.EOF {
+ fmt.Println("\nGoodbye!")
+ return
+ }
+ fmt.Printf("Error reading input: %v\n", err)
+ continue
+ }
+
+ input := strings.TrimSpace(line)
+ if input == "" {
+ continue
+ }
+
+ if input == "exit" || input == "quit" {
+ fmt.Println("Goodbye!")
+ return
+ }
+
+ ctx := context.Background()
+ response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
+ if err != nil {
+ fmt.Printf("Error: %v\n", err)
+ continue
+ }
+
+ fmt.Printf("\n%s %s\n\n", logo, response)
+ }
+}
+
+func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
+ reader := bufio.NewReader(os.Stdin)
+ for {
+ fmt.Print(fmt.Sprintf("%s You: ", logo))
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ if err == io.EOF {
+ fmt.Println("\nGoodbye!")
+ return
+ }
+ fmt.Printf("Error reading input: %v\n", err)
+ continue
+ }
+
+ input := strings.TrimSpace(line)
+ if input == "" {
+ continue
+ }
+
+ if input == "exit" || input == "quit" {
+ fmt.Println("Goodbye!")
+ return
+ }
+
+ ctx := context.Background()
+ response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
+ if err != nil {
+ fmt.Printf("Error: %v\n", err)
+ continue
+ }
+
+ fmt.Printf("\n%s %s\n\n", logo, response)
+ }
+}
diff --git a/cmd/picoclaw/cmd_auth.go b/cmd/picoclaw/cmd_auth.go
new file mode 100644
index 000000000..5bed7f116
--- /dev/null
+++ b/cmd/picoclaw/cmd_auth.go
@@ -0,0 +1,512 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/auth"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity"
+
+func authCmd() {
+ if len(os.Args) < 3 {
+ authHelp()
+ return
+ }
+
+ switch os.Args[2] {
+ case "login":
+ authLoginCmd()
+ case "logout":
+ authLogoutCmd()
+ case "status":
+ authStatusCmd()
+ case "models":
+ authModelsCmd()
+ default:
+ fmt.Printf("Unknown auth command: %s\n", os.Args[2])
+ authHelp()
+ }
+}
+
+func authHelp() {
+ fmt.Println("\nAuth commands:")
+ fmt.Println(" login Login via OAuth or paste token")
+ fmt.Println(" logout Remove stored credentials")
+ fmt.Println(" status Show current auth status")
+ fmt.Println(" models List available Antigravity models")
+ fmt.Println()
+ fmt.Println("Login options:")
+ fmt.Println(" --provider Provider to login with (openai, anthropic, google-antigravity)")
+ fmt.Println(" --device-code Use device code flow (for headless environments)")
+ fmt.Println()
+ fmt.Println("Examples:")
+ fmt.Println(" picoclaw auth login --provider openai")
+ fmt.Println(" picoclaw auth login --provider openai --device-code")
+ fmt.Println(" picoclaw auth login --provider anthropic")
+ fmt.Println(" picoclaw auth login --provider google-antigravity")
+ fmt.Println(" picoclaw auth models")
+ fmt.Println(" picoclaw auth logout --provider openai")
+ fmt.Println(" picoclaw auth status")
+}
+
+func authLoginCmd() {
+ provider := ""
+ useDeviceCode := false
+
+ args := os.Args[3:]
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--provider", "-p":
+ if i+1 < len(args) {
+ provider = args[i+1]
+ i++
+ }
+ case "--device-code":
+ useDeviceCode = true
+ }
+ }
+
+ if provider == "" {
+ fmt.Println("Error: --provider is required")
+ fmt.Println(supportedProvidersMsg)
+ return
+ }
+
+ switch provider {
+ case "openai":
+ authLoginOpenAI(useDeviceCode)
+ case "anthropic":
+ authLoginPasteToken(provider)
+ case "google-antigravity", "antigravity":
+ authLoginGoogleAntigravity()
+ default:
+ fmt.Printf("Unsupported provider: %s\n", provider)
+ fmt.Println(supportedProvidersMsg)
+ }
+}
+
+func authLoginOpenAI(useDeviceCode bool) {
+ cfg := auth.OpenAIOAuthConfig()
+
+ var cred *auth.AuthCredential
+ var err error
+
+ if useDeviceCode {
+ cred, err = auth.LoginDeviceCode(cfg)
+ } else {
+ cred, err = auth.LoginBrowser(cfg)
+ }
+
+ if err != nil {
+ fmt.Printf("Login failed: %v\n", err)
+ os.Exit(1)
+ }
+
+ if err := auth.SetCredential("openai", cred); err != nil {
+ fmt.Printf("Failed to save credentials: %v\n", err)
+ os.Exit(1)
+ }
+
+ appCfg, err := loadConfig()
+ if err == nil {
+ // Update Providers (legacy format)
+ appCfg.Providers.OpenAI.AuthMethod = "oauth"
+
+ // Update or add openai in ModelList
+ foundOpenAI := false
+ for i := range appCfg.ModelList {
+ if isOpenAIModel(appCfg.ModelList[i].Model) {
+ appCfg.ModelList[i].AuthMethod = "oauth"
+ foundOpenAI = true
+ break
+ }
+ }
+
+ // If no openai in ModelList, add it
+ if !foundOpenAI {
+ appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
+ ModelName: "gpt-5.2",
+ Model: "openai/gpt-5.2",
+ AuthMethod: "oauth",
+ })
+ }
+
+ // Update default model to use OpenAI
+ appCfg.Agents.Defaults.Model = "gpt-5.2"
+
+ if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
+ fmt.Printf("Warning: could not update config: %v\n", err)
+ }
+ }
+
+ fmt.Println("Login successful!")
+ if cred.AccountID != "" {
+ fmt.Printf("Account: %s\n", cred.AccountID)
+ }
+ fmt.Println("Default model set to: gpt-5.2")
+}
+
+func authLoginGoogleAntigravity() {
+ cfg := auth.GoogleAntigravityOAuthConfig()
+
+ cred, err := auth.LoginBrowser(cfg)
+ if err != nil {
+ fmt.Printf("Login failed: %v\n", err)
+ os.Exit(1)
+ }
+
+ cred.Provider = "google-antigravity"
+
+ // Fetch user email from Google userinfo
+ email, err := fetchGoogleUserEmail(cred.AccessToken)
+ if err != nil {
+ fmt.Printf("Warning: could not fetch email: %v\n", err)
+ } else {
+ cred.Email = email
+ fmt.Printf("Email: %s\n", email)
+ }
+
+ // Fetch Cloud Code Assist project ID
+ projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken)
+ if err != nil {
+ fmt.Printf("Warning: could not fetch project ID: %v\n", err)
+ fmt.Println("You may need Google Cloud Code Assist enabled on your account.")
+ } else {
+ cred.ProjectID = projectID
+ fmt.Printf("Project: %s\n", projectID)
+ }
+
+ if err := auth.SetCredential("google-antigravity", cred); err != nil {
+ fmt.Printf("Failed to save credentials: %v\n", err)
+ os.Exit(1)
+ }
+
+ appCfg, err := loadConfig()
+ if err == nil {
+ // Update Providers (legacy format, for backward compatibility)
+ appCfg.Providers.Antigravity.AuthMethod = "oauth"
+
+ // Update or add antigravity in ModelList
+ foundAntigravity := false
+ for i := range appCfg.ModelList {
+ if isAntigravityModel(appCfg.ModelList[i].Model) {
+ appCfg.ModelList[i].AuthMethod = "oauth"
+ foundAntigravity = true
+ break
+ }
+ }
+
+ // If no antigravity in ModelList, add it
+ if !foundAntigravity {
+ appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
+ ModelName: "gemini-flash",
+ Model: "antigravity/gemini-3-flash",
+ AuthMethod: "oauth",
+ })
+ }
+
+ // Update default model
+ appCfg.Agents.Defaults.Model = "gemini-flash"
+
+ if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
+ fmt.Printf("Warning: could not update config: %v\n", err)
+ }
+ }
+
+ fmt.Println("\n✓ Google Antigravity login successful!")
+ fmt.Println("Default model set to: gemini-flash")
+ fmt.Println("Try it: picoclaw agent -m \"Hello world\"")
+}
+
+func fetchGoogleUserEmail(accessToken string) (string, error) {
+ req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("userinfo request failed: %s", string(body))
+ }
+
+ var userInfo struct {
+ Email string `json:"email"`
+ }
+ if err := json.Unmarshal(body, &userInfo); err != nil {
+ return "", err
+ }
+ return userInfo.Email, nil
+}
+
+func authLoginPasteToken(provider string) {
+ cred, err := auth.LoginPasteToken(provider, os.Stdin)
+ if err != nil {
+ fmt.Printf("Login failed: %v\n", err)
+ os.Exit(1)
+ }
+
+ if err := auth.SetCredential(provider, cred); err != nil {
+ fmt.Printf("Failed to save credentials: %v\n", err)
+ os.Exit(1)
+ }
+
+ appCfg, err := loadConfig()
+ if err == nil {
+ switch provider {
+ case "anthropic":
+ appCfg.Providers.Anthropic.AuthMethod = "token"
+ // Update ModelList
+ found := false
+ for i := range appCfg.ModelList {
+ if isAnthropicModel(appCfg.ModelList[i].Model) {
+ appCfg.ModelList[i].AuthMethod = "token"
+ found = true
+ break
+ }
+ }
+ if !found {
+ appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
+ ModelName: "claude-sonnet-4.6",
+ Model: "anthropic/claude-sonnet-4.6",
+ AuthMethod: "token",
+ })
+ }
+ // Update default model
+ appCfg.Agents.Defaults.Model = "claude-sonnet-4.6"
+ case "openai":
+ appCfg.Providers.OpenAI.AuthMethod = "token"
+ // Update ModelList
+ found := false
+ for i := range appCfg.ModelList {
+ if isOpenAIModel(appCfg.ModelList[i].Model) {
+ appCfg.ModelList[i].AuthMethod = "token"
+ found = true
+ break
+ }
+ }
+ if !found {
+ appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
+ ModelName: "gpt-5.2",
+ Model: "openai/gpt-5.2",
+ AuthMethod: "token",
+ })
+ }
+ // Update default model
+ appCfg.Agents.Defaults.Model = "gpt-5.2"
+ }
+ if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
+ fmt.Printf("Warning: could not update config: %v\n", err)
+ }
+ }
+
+ fmt.Printf("Token saved for %s!\n", provider)
+ fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.Model)
+}
+
+func authLogoutCmd() {
+ provider := ""
+
+ args := os.Args[3:]
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--provider", "-p":
+ if i+1 < len(args) {
+ provider = args[i+1]
+ i++
+ }
+ }
+ }
+
+ if provider != "" {
+ if err := auth.DeleteCredential(provider); err != nil {
+ fmt.Printf("Failed to remove credentials: %v\n", err)
+ os.Exit(1)
+ }
+
+ appCfg, err := loadConfig()
+ if err == nil {
+ // Clear AuthMethod in ModelList
+ for i := range appCfg.ModelList {
+ switch provider {
+ case "openai":
+ if isOpenAIModel(appCfg.ModelList[i].Model) {
+ appCfg.ModelList[i].AuthMethod = ""
+ }
+ case "anthropic":
+ if isAnthropicModel(appCfg.ModelList[i].Model) {
+ appCfg.ModelList[i].AuthMethod = ""
+ }
+ case "google-antigravity", "antigravity":
+ if isAntigravityModel(appCfg.ModelList[i].Model) {
+ appCfg.ModelList[i].AuthMethod = ""
+ }
+ }
+ }
+ // Clear AuthMethod in Providers (legacy)
+ switch provider {
+ case "openai":
+ appCfg.Providers.OpenAI.AuthMethod = ""
+ case "anthropic":
+ appCfg.Providers.Anthropic.AuthMethod = ""
+ case "google-antigravity", "antigravity":
+ appCfg.Providers.Antigravity.AuthMethod = ""
+ }
+ config.SaveConfig(getConfigPath(), appCfg)
+ }
+
+ fmt.Printf("Logged out from %s\n", provider)
+ } else {
+ if err := auth.DeleteAllCredentials(); err != nil {
+ fmt.Printf("Failed to remove credentials: %v\n", err)
+ os.Exit(1)
+ }
+
+ appCfg, err := loadConfig()
+ if err == nil {
+ // Clear all AuthMethods in ModelList
+ for i := range appCfg.ModelList {
+ appCfg.ModelList[i].AuthMethod = ""
+ }
+ // Clear all AuthMethods in Providers (legacy)
+ appCfg.Providers.OpenAI.AuthMethod = ""
+ appCfg.Providers.Anthropic.AuthMethod = ""
+ appCfg.Providers.Antigravity.AuthMethod = ""
+ config.SaveConfig(getConfigPath(), appCfg)
+ }
+
+ fmt.Println("Logged out from all providers")
+ }
+}
+
+func authStatusCmd() {
+ store, err := auth.LoadStore()
+ if err != nil {
+ fmt.Printf("Error loading auth store: %v\n", err)
+ return
+ }
+
+ if len(store.Credentials) == 0 {
+ fmt.Println("No authenticated providers.")
+ fmt.Println("Run: picoclaw auth login --provider ")
+ return
+ }
+
+ fmt.Println("\nAuthenticated Providers:")
+ fmt.Println("------------------------")
+ for provider, cred := range store.Credentials {
+ status := "active"
+ if cred.IsExpired() {
+ status = "expired"
+ } else if cred.NeedsRefresh() {
+ status = "needs refresh"
+ }
+
+ fmt.Printf(" %s:\n", provider)
+ fmt.Printf(" Method: %s\n", cred.AuthMethod)
+ fmt.Printf(" Status: %s\n", status)
+ if cred.AccountID != "" {
+ fmt.Printf(" Account: %s\n", cred.AccountID)
+ }
+ if cred.Email != "" {
+ fmt.Printf(" Email: %s\n", cred.Email)
+ }
+ if cred.ProjectID != "" {
+ fmt.Printf(" Project: %s\n", cred.ProjectID)
+ }
+ if !cred.ExpiresAt.IsZero() {
+ fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
+ }
+ }
+}
+
+func authModelsCmd() {
+ cred, err := auth.GetCredential("google-antigravity")
+ if err != nil || cred == nil {
+ fmt.Println("Not logged in to Google Antigravity.")
+ fmt.Println("Run: picoclaw auth login --provider google-antigravity")
+ return
+ }
+
+ // Refresh token if needed
+ if cred.NeedsRefresh() && cred.RefreshToken != "" {
+ oauthCfg := auth.GoogleAntigravityOAuthConfig()
+ refreshed, refreshErr := auth.RefreshAccessToken(cred, oauthCfg)
+ if refreshErr == nil {
+ cred = refreshed
+ _ = auth.SetCredential("google-antigravity", cred)
+ }
+ }
+
+ projectID := cred.ProjectID
+ if projectID == "" {
+ fmt.Println("No project ID stored. Try logging in again.")
+ return
+ }
+
+ fmt.Printf("Fetching models for project: %s\n\n", projectID)
+
+ models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
+ if err != nil {
+ fmt.Printf("Error fetching models: %v\n", err)
+ return
+ }
+
+ if len(models) == 0 {
+ fmt.Println("No models available.")
+ return
+ }
+
+ fmt.Println("Available Antigravity Models:")
+ fmt.Println("-----------------------------")
+ for _, m := range models {
+ status := "✓"
+ if m.IsExhausted {
+ status = "✗ (quota exhausted)"
+ }
+ name := m.ID
+ if m.DisplayName != "" {
+ name = fmt.Sprintf("%s (%s)", m.ID, m.DisplayName)
+ }
+ fmt.Printf(" %s %s\n", status, name)
+ }
+}
+
+// isAntigravityModel checks if a model string belongs to antigravity provider
+func isAntigravityModel(model string) bool {
+ return model == "antigravity" ||
+ model == "google-antigravity" ||
+ strings.HasPrefix(model, "antigravity/") ||
+ strings.HasPrefix(model, "google-antigravity/")
+}
+
+// isOpenAIModel checks if a model string belongs to openai provider
+func isOpenAIModel(model string) bool {
+ return model == "openai" ||
+ strings.HasPrefix(model, "openai/")
+}
+
+// isAnthropicModel checks if a model string belongs to anthropic provider
+func isAnthropicModel(model string) bool {
+ return model == "anthropic" ||
+ strings.HasPrefix(model, "anthropic/")
+}
diff --git a/cmd/picoclaw/cmd_cron.go b/cmd/picoclaw/cmd_cron.go
new file mode 100644
index 000000000..8c42bde06
--- /dev/null
+++ b/cmd/picoclaw/cmd_cron.go
@@ -0,0 +1,227 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/cron"
+)
+
+func cronCmd() {
+ if len(os.Args) < 3 {
+ cronHelp()
+ return
+ }
+
+ subcommand := os.Args[2]
+
+ // Load config to get workspace path
+ cfg, err := loadConfig()
+ if err != nil {
+ fmt.Printf("Error loading config: %v\n", err)
+ return
+ }
+
+ cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
+
+ switch subcommand {
+ case "list":
+ cronListCmd(cronStorePath)
+ case "add":
+ cronAddCmd(cronStorePath)
+ case "remove":
+ if len(os.Args) < 4 {
+ fmt.Println("Usage: picoclaw cron remove ")
+ return
+ }
+ cronRemoveCmd(cronStorePath, os.Args[3])
+ case "enable":
+ cronEnableCmd(cronStorePath, false)
+ case "disable":
+ cronEnableCmd(cronStorePath, true)
+ default:
+ fmt.Printf("Unknown cron command: %s\n", subcommand)
+ cronHelp()
+ }
+}
+
+func cronHelp() {
+ fmt.Println("\nCron commands:")
+ fmt.Println(" list List all scheduled jobs")
+ fmt.Println(" add Add a new scheduled job")
+ fmt.Println(" remove Remove a job by ID")
+ fmt.Println(" enable Enable a job")
+ fmt.Println(" disable Disable a job")
+ fmt.Println()
+ fmt.Println("Add options:")
+ fmt.Println(" -n, --name Job name")
+ fmt.Println(" -m, --message Message for agent")
+ fmt.Println(" -e, --every Run every N seconds")
+ fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')")
+ fmt.Println(" -d, --deliver Deliver response to channel")
+ fmt.Println(" --to Recipient for delivery")
+ fmt.Println(" --channel Channel for delivery")
+}
+
+func cronListCmd(storePath string) {
+ cs := cron.NewCronService(storePath, nil)
+ jobs := cs.ListJobs(true) // Show all jobs, including disabled
+
+ if len(jobs) == 0 {
+ fmt.Println("No scheduled jobs.")
+ return
+ }
+
+ fmt.Println("\nScheduled Jobs:")
+ fmt.Println("----------------")
+ for _, job := range jobs {
+ var schedule string
+ if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
+ schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
+ } else if job.Schedule.Kind == "cron" {
+ schedule = job.Schedule.Expr
+ } else {
+ schedule = "one-time"
+ }
+
+ nextRun := "scheduled"
+ if job.State.NextRunAtMS != nil {
+ nextTime := time.UnixMilli(*job.State.NextRunAtMS)
+ nextRun = nextTime.Format("2006-01-02 15:04")
+ }
+
+ status := "enabled"
+ if !job.Enabled {
+ status = "disabled"
+ }
+
+ fmt.Printf(" %s (%s)\n", job.Name, job.ID)
+ fmt.Printf(" Schedule: %s\n", schedule)
+ fmt.Printf(" Status: %s\n", status)
+ fmt.Printf(" Next run: %s\n", nextRun)
+ }
+}
+
+func cronAddCmd(storePath string) {
+ name := ""
+ message := ""
+ var everySec *int64
+ cronExpr := ""
+ deliver := false
+ channel := ""
+ to := ""
+
+ args := os.Args[3:]
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "-n", "--name":
+ if i+1 < len(args) {
+ name = args[i+1]
+ i++
+ }
+ case "-m", "--message":
+ if i+1 < len(args) {
+ message = args[i+1]
+ i++
+ }
+ case "-e", "--every":
+ if i+1 < len(args) {
+ var sec int64
+ fmt.Sscanf(args[i+1], "%d", &sec)
+ everySec = &sec
+ i++
+ }
+ case "-c", "--cron":
+ if i+1 < len(args) {
+ cronExpr = args[i+1]
+ i++
+ }
+ case "-d", "--deliver":
+ deliver = true
+ case "--to":
+ if i+1 < len(args) {
+ to = args[i+1]
+ i++
+ }
+ case "--channel":
+ if i+1 < len(args) {
+ channel = args[i+1]
+ i++
+ }
+ }
+ }
+
+ if name == "" {
+ fmt.Println("Error: --name is required")
+ return
+ }
+
+ if message == "" {
+ fmt.Println("Error: --message is required")
+ return
+ }
+
+ if everySec == nil && cronExpr == "" {
+ fmt.Println("Error: Either --every or --cron must be specified")
+ return
+ }
+
+ var schedule cron.CronSchedule
+ if everySec != nil {
+ everyMS := *everySec * 1000
+ schedule = cron.CronSchedule{
+ Kind: "every",
+ EveryMS: &everyMS,
+ }
+ } else {
+ schedule = cron.CronSchedule{
+ Kind: "cron",
+ Expr: cronExpr,
+ }
+ }
+
+ cs := cron.NewCronService(storePath, nil)
+ job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
+ if err != nil {
+ fmt.Printf("Error adding job: %v\n", err)
+ return
+ }
+
+ fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
+}
+
+func cronRemoveCmd(storePath, jobID string) {
+ cs := cron.NewCronService(storePath, nil)
+ if cs.RemoveJob(jobID) {
+ fmt.Printf("✓ Removed job %s\n", jobID)
+ } else {
+ fmt.Printf("✗ Job %s not found\n", jobID)
+ }
+}
+
+func cronEnableCmd(storePath string, disable bool) {
+ if len(os.Args) < 4 {
+ fmt.Println("Usage: picoclaw cron enable/disable ")
+ return
+ }
+
+ jobID := os.Args[3]
+ cs := cron.NewCronService(storePath, nil)
+ enabled := !disable
+
+ job := cs.EnableJob(jobID, enabled)
+ if job != nil {
+ status := "enabled"
+ if disable {
+ status = "disabled"
+ }
+ fmt.Printf("✓ Job '%s' %s\n", job.Name, status)
+ } else {
+ fmt.Printf("✗ Job %s not found\n", jobID)
+ }
+}
diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go
new file mode 100644
index 000000000..1f1bf5491
--- /dev/null
+++ b/cmd/picoclaw/cmd_gateway.go
@@ -0,0 +1,223 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/agent"
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/cron"
+ "github.com/sipeed/picoclaw/pkg/devices"
+ "github.com/sipeed/picoclaw/pkg/health"
+ "github.com/sipeed/picoclaw/pkg/heartbeat"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/state"
+ "github.com/sipeed/picoclaw/pkg/tools"
+ "github.com/sipeed/picoclaw/pkg/voice"
+)
+
+func gatewayCmd() {
+ // Check for --debug flag
+ args := os.Args[2:]
+ for _, arg := range args {
+ if arg == "--debug" || arg == "-d" {
+ logger.SetLevel(logger.DEBUG)
+ fmt.Println("🔍 Debug mode enabled")
+ break
+ }
+ }
+
+ cfg, err := loadConfig()
+ if err != nil {
+ fmt.Printf("Error loading config: %v\n", err)
+ os.Exit(1)
+ }
+
+ provider, modelID, err := providers.CreateProvider(cfg)
+ if err != nil {
+ fmt.Printf("Error creating provider: %v\n", err)
+ os.Exit(1)
+ }
+ // Use the resolved model ID from provider creation
+ if modelID != "" {
+ cfg.Agents.Defaults.Model = modelID
+ }
+
+ msgBus := bus.NewMessageBus()
+ agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
+
+ // Print agent startup info
+ fmt.Println("\n📦 Agent Status:")
+ startupInfo := agentLoop.GetStartupInfo()
+ toolsInfo := startupInfo["tools"].(map[string]interface{})
+ skillsInfo := startupInfo["skills"].(map[string]interface{})
+ fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
+ fmt.Printf(" • Skills: %d/%d available\n",
+ skillsInfo["available"],
+ skillsInfo["total"])
+
+ // Log to file as well
+ logger.InfoCF("agent", "Agent initialized",
+ map[string]interface{}{
+ "tools_count": toolsInfo["count"],
+ "skills_total": skillsInfo["total"],
+ "skills_available": skillsInfo["available"],
+ })
+
+ // Setup cron tool and service
+ execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
+ cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg)
+
+ heartbeatService := heartbeat.NewHeartbeatService(
+ cfg.WorkspacePath(),
+ cfg.Heartbeat.Interval,
+ cfg.Heartbeat.Enabled,
+ )
+ heartbeatService.SetBus(msgBus)
+ heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
+ // Use cli:direct as fallback if no valid channel
+ if channel == "" || chatID == "" {
+ channel, chatID = "cli", "direct"
+ }
+ // Use ProcessHeartbeat - no session history, each heartbeat is independent
+ response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
+ if err != nil {
+ return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
+ }
+ if response == "HEARTBEAT_OK" {
+ return tools.SilentResult("Heartbeat OK")
+ }
+ // For heartbeat, always return silent - the subagent result will be
+ // sent to user via processSystemMessage when the async task completes
+ return tools.SilentResult(response)
+ })
+
+ channelManager, err := channels.NewManager(cfg, msgBus)
+ if err != nil {
+ fmt.Printf("Error creating channel manager: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Inject channel manager into agent loop for command handling
+ agentLoop.SetChannelManager(channelManager)
+
+ var transcriber *voice.GroqTranscriber
+ if cfg.Providers.Groq.APIKey != "" {
+ transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey)
+ logger.InfoC("voice", "Groq voice transcription enabled")
+ }
+
+ if transcriber != nil {
+ if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
+ if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
+ tc.SetTranscriber(transcriber)
+ logger.InfoC("voice", "Groq transcription attached to Telegram channel")
+ }
+ }
+ if discordChannel, ok := channelManager.GetChannel("discord"); ok {
+ if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
+ dc.SetTranscriber(transcriber)
+ logger.InfoC("voice", "Groq transcription attached to Discord channel")
+ }
+ }
+ if slackChannel, ok := channelManager.GetChannel("slack"); ok {
+ if sc, ok := slackChannel.(*channels.SlackChannel); ok {
+ sc.SetTranscriber(transcriber)
+ logger.InfoC("voice", "Groq transcription attached to Slack channel")
+ }
+ }
+ }
+
+ enabledChannels := channelManager.GetEnabledChannels()
+ if len(enabledChannels) > 0 {
+ fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
+ } else {
+ fmt.Println("⚠ Warning: No channels enabled")
+ }
+
+ fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
+ fmt.Println("Press Ctrl+C to stop")
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ if err := cronService.Start(); err != nil {
+ fmt.Printf("Error starting cron service: %v\n", err)
+ }
+ fmt.Println("✓ Cron service started")
+
+ if err := heartbeatService.Start(); err != nil {
+ fmt.Printf("Error starting heartbeat service: %v\n", err)
+ }
+ fmt.Println("✓ Heartbeat service started")
+
+ stateManager := state.NewManager(cfg.WorkspacePath())
+ deviceService := devices.NewService(devices.Config{
+ Enabled: cfg.Devices.Enabled,
+ MonitorUSB: cfg.Devices.MonitorUSB,
+ }, stateManager)
+ deviceService.SetBus(msgBus)
+ if err := deviceService.Start(ctx); err != nil {
+ fmt.Printf("Error starting device service: %v\n", err)
+ } else if cfg.Devices.Enabled {
+ fmt.Println("✓ Device event service started")
+ }
+
+ if err := channelManager.StartAll(ctx); err != nil {
+ fmt.Printf("Error starting channels: %v\n", err)
+ }
+
+ healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
+ go func() {
+ if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
+ logger.ErrorCF("health", "Health server error", map[string]interface{}{"error": err.Error()})
+ }
+ }()
+ fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
+
+ go agentLoop.Run(ctx)
+
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, os.Interrupt)
+ <-sigChan
+
+ fmt.Println("\nShutting down...")
+ cancel()
+ healthServer.Stop(context.Background())
+ deviceService.Stop()
+ heartbeatService.Stop()
+ cronService.Stop()
+ agentLoop.Stop()
+ channelManager.StopAll(ctx)
+ fmt.Println("✓ Gateway stopped")
+}
+
+func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, cfg *config.Config) *cron.CronService {
+ cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
+
+ // Create cron service
+ cronService := cron.NewCronService(cronStorePath, nil)
+
+ // Create and register CronTool
+ cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
+ agentLoop.RegisterTool(cronTool)
+
+ // Set the onJob handler
+ cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
+ result := cronTool.ExecuteJob(context.Background(), job)
+ return result, nil
+ })
+
+ return cronService
+}
diff --git a/cmd/picoclaw/cmd_migrate.go b/cmd/picoclaw/cmd_migrate.go
new file mode 100644
index 000000000..86d4903ef
--- /dev/null
+++ b/cmd/picoclaw/cmd_migrate.go
@@ -0,0 +1,81 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/sipeed/picoclaw/pkg/migrate"
+)
+
+func migrateCmd() {
+ if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") {
+ migrateHelp()
+ return
+ }
+
+ opts := migrate.Options{}
+
+ args := os.Args[2:]
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--dry-run":
+ opts.DryRun = true
+ case "--config-only":
+ opts.ConfigOnly = true
+ case "--workspace-only":
+ opts.WorkspaceOnly = true
+ case "--force":
+ opts.Force = true
+ case "--refresh":
+ opts.Refresh = true
+ case "--openclaw-home":
+ if i+1 < len(args) {
+ opts.OpenClawHome = args[i+1]
+ i++
+ }
+ case "--picoclaw-home":
+ if i+1 < len(args) {
+ opts.PicoClawHome = args[i+1]
+ i++
+ }
+ default:
+ fmt.Printf("Unknown flag: %s\n", args[i])
+ migrateHelp()
+ os.Exit(1)
+ }
+ }
+
+ result, err := migrate.Run(opts)
+ if err != nil {
+ fmt.Printf("Error: %v\n", err)
+ os.Exit(1)
+ }
+
+ if !opts.DryRun {
+ migrate.PrintSummary(result)
+ }
+}
+
+func migrateHelp() {
+ fmt.Println("\nMigrate from OpenClaw to PicoClaw")
+ fmt.Println()
+ fmt.Println("Usage: picoclaw migrate [options]")
+ fmt.Println()
+ fmt.Println("Options:")
+ fmt.Println(" --dry-run Show what would be migrated without making changes")
+ fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)")
+ fmt.Println(" --config-only Only migrate config, skip workspace files")
+ fmt.Println(" --workspace-only Only migrate workspace files, skip config")
+ fmt.Println(" --force Skip confirmation prompts")
+ fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)")
+ fmt.Println(" --picoclaw-home Override PicoClaw home directory (default: ~/.picoclaw)")
+ fmt.Println()
+ fmt.Println("Examples:")
+ fmt.Println(" picoclaw migrate Detect and migrate from OpenClaw")
+ fmt.Println(" picoclaw migrate --dry-run Show what would be migrated")
+ fmt.Println(" picoclaw migrate --refresh Re-sync workspace files")
+ fmt.Println(" picoclaw migrate --force Migrate without confirmation")
+}
diff --git a/cmd/picoclaw/cmd_onboard.go b/cmd/picoclaw/cmd_onboard.go
new file mode 100644
index 000000000..6e61e3267
--- /dev/null
+++ b/cmd/picoclaw/cmd_onboard.go
@@ -0,0 +1,108 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "embed"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+//go:generate cp -r ../../workspace .
+//go:embed workspace
+var embeddedFiles embed.FS
+
+func onboard() {
+ configPath := getConfigPath()
+
+ if _, err := os.Stat(configPath); err == nil {
+ fmt.Printf("Config already exists at %s\n", configPath)
+ fmt.Print("Overwrite? (y/n): ")
+ var response string
+ fmt.Scanln(&response)
+ if response != "y" {
+ fmt.Println("Aborted.")
+ return
+ }
+ }
+
+ cfg := config.DefaultConfig()
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ fmt.Printf("Error saving config: %v\n", err)
+ os.Exit(1)
+ }
+
+ workspace := cfg.WorkspacePath()
+ createWorkspaceTemplates(workspace)
+
+ fmt.Printf("%s picoclaw is ready!\n", logo)
+ fmt.Println("\nNext steps:")
+ fmt.Println(" 1. Add your API key to", configPath)
+ fmt.Println("")
+ fmt.Println(" Recommended:")
+ fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
+ fmt.Println(" - Ollama: https://ollama.com (local, free)")
+ fmt.Println("")
+ fmt.Println(" See README.md for 17+ supported providers.")
+ fmt.Println("")
+ fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
+}
+
+func copyEmbeddedToTarget(targetDir string) error {
+ // Ensure target directory exists
+ if err := os.MkdirAll(targetDir, 0755); err != nil {
+ return fmt.Errorf("Failed to create target directory: %w", err)
+ }
+
+ // Walk through all files in embed.FS
+ err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ // Skip directories
+ if d.IsDir() {
+ return nil
+ }
+
+ // Read embedded file
+ data, err := embeddedFiles.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
+ }
+
+ new_path, err := filepath.Rel("workspace", path)
+ if err != nil {
+ return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
+ }
+
+ // Build target file path
+ targetPath := filepath.Join(targetDir, new_path)
+
+ // Ensure target file's directory exists
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
+ return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
+ }
+
+ // Write file
+ if err := os.WriteFile(targetPath, data, 0644); err != nil {
+ return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
+ }
+
+ return nil
+ })
+
+ return err
+}
+
+func createWorkspaceTemplates(workspace string) {
+ err := copyEmbeddedToTarget(workspace)
+ if err != nil {
+ fmt.Printf("Error copying workspace templates: %v\n", err)
+ }
+}
diff --git a/cmd/picoclaw/cmd_skills.go b/cmd/picoclaw/cmd_skills.go
new file mode 100644
index 000000000..9ea38dcf6
--- /dev/null
+++ b/cmd/picoclaw/cmd_skills.go
@@ -0,0 +1,216 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/skills"
+)
+
+func skillsHelp() {
+ fmt.Println("\nSkills commands:")
+ fmt.Println(" list List installed skills")
+ fmt.Println(" install Install skill from GitHub")
+ fmt.Println(" install-builtin Install all builtin skills to workspace")
+ fmt.Println(" list-builtin List available builtin skills")
+ fmt.Println(" remove Remove installed skill")
+ fmt.Println(" search Search available skills")
+ fmt.Println(" show Show skill details")
+ fmt.Println()
+ fmt.Println("Examples:")
+ fmt.Println(" picoclaw skills list")
+ fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
+ fmt.Println(" picoclaw skills install-builtin")
+ fmt.Println(" picoclaw skills list-builtin")
+ fmt.Println(" picoclaw skills remove weather")
+}
+
+func skillsListCmd(loader *skills.SkillsLoader) {
+ allSkills := loader.ListSkills()
+
+ if len(allSkills) == 0 {
+ fmt.Println("No skills installed.")
+ return
+ }
+
+ fmt.Println("\nInstalled Skills:")
+ fmt.Println("------------------")
+ for _, skill := range allSkills {
+ fmt.Printf(" ✓ %s (%s)\n", skill.Name, skill.Source)
+ if skill.Description != "" {
+ fmt.Printf(" %s\n", skill.Description)
+ }
+ }
+}
+
+func skillsInstallCmd(installer *skills.SkillInstaller) {
+ if len(os.Args) < 4 {
+ fmt.Println("Usage: picoclaw skills install ")
+ fmt.Println("Example: picoclaw skills install sipeed/picoclaw-skills/weather")
+ return
+ }
+
+ repo := os.Args[3]
+ fmt.Printf("Installing skill from %s...\n", repo)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ if err := installer.InstallFromGitHub(ctx, repo); err != nil {
+ fmt.Printf("✗ Failed to install skill: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Printf("✓ Skill '%s' installed successfully!\n", filepath.Base(repo))
+}
+
+func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
+ fmt.Printf("Removing skill '%s'...\n", skillName)
+
+ if err := installer.Uninstall(skillName); err != nil {
+ fmt.Printf("✗ Failed to remove skill: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
+}
+
+func skillsInstallBuiltinCmd(workspace string) {
+ builtinSkillsDir := "./picoclaw/skills"
+ workspaceSkillsDir := filepath.Join(workspace, "skills")
+
+ fmt.Printf("Copying builtin skills to workspace...\n")
+
+ skillsToInstall := []string{
+ "weather",
+ "news",
+ "stock",
+ "calculator",
+ }
+
+ for _, skillName := range skillsToInstall {
+ builtinPath := filepath.Join(builtinSkillsDir, skillName)
+ workspacePath := filepath.Join(workspaceSkillsDir, skillName)
+
+ if _, err := os.Stat(builtinPath); err != nil {
+ fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err)
+ continue
+ }
+
+ if err := os.MkdirAll(workspacePath, 0755); err != nil {
+ fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
+ continue
+ }
+
+ if err := copyDirectory(builtinPath, workspacePath); err != nil {
+ fmt.Printf("✗ Failed to copy %s: %v\n", skillName, err)
+ }
+ }
+
+ fmt.Println("\n✓ All builtin skills installed!")
+ fmt.Println("Now you can use them in your workspace.")
+}
+
+func skillsListBuiltinCmd() {
+ cfg, err := loadConfig()
+ if err != nil {
+ fmt.Printf("Error loading config: %v\n", err)
+ return
+ }
+ builtinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), "picoclaw", "skills")
+
+ fmt.Println("\nAvailable Builtin Skills:")
+ fmt.Println("-----------------------")
+
+ entries, err := os.ReadDir(builtinSkillsDir)
+ if err != nil {
+ fmt.Printf("Error reading builtin skills: %v\n", err)
+ return
+ }
+
+ if len(entries) == 0 {
+ fmt.Println("No builtin skills available.")
+ return
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ skillName := entry.Name()
+ skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md")
+
+ description := "No description"
+ if _, err := os.Stat(skillFile); err == nil {
+ data, err := os.ReadFile(skillFile)
+ if err == nil {
+ content := string(data)
+ if idx := strings.Index(content, "\n"); idx > 0 {
+ firstLine := content[:idx]
+ if strings.Contains(firstLine, "description:") {
+ descLine := strings.Index(content[idx:], "\n")
+ if descLine > 0 {
+ description = strings.TrimSpace(content[idx+descLine : idx+descLine])
+ }
+ }
+ }
+ }
+ }
+ status := "✓"
+ fmt.Printf(" %s %s\n", status, entry.Name())
+ if description != "" {
+ fmt.Printf(" %s\n", description)
+ }
+ }
+ }
+}
+
+func skillsSearchCmd(installer *skills.SkillInstaller) {
+ fmt.Println("Searching for available skills...")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ availableSkills, err := installer.ListAvailableSkills(ctx)
+ if err != nil {
+ fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
+ return
+ }
+
+ if len(availableSkills) == 0 {
+ fmt.Println("No skills available.")
+ return
+ }
+
+ fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills))
+ fmt.Println("--------------------")
+ for _, skill := range availableSkills {
+ fmt.Printf(" 📦 %s\n", skill.Name)
+ fmt.Printf(" %s\n", skill.Description)
+ fmt.Printf(" Repo: %s\n", skill.Repository)
+ if skill.Author != "" {
+ fmt.Printf(" Author: %s\n", skill.Author)
+ }
+ if len(skill.Tags) > 0 {
+ fmt.Printf(" Tags: %v\n", skill.Tags)
+ }
+ fmt.Println()
+ }
+}
+
+func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
+ content, ok := loader.LoadSkill(skillName)
+ if !ok {
+ fmt.Printf("✗ Skill '%s' not found\n", skillName)
+ return
+ }
+
+ fmt.Printf("\n📦 Skill: %s\n", skillName)
+ fmt.Println("----------------------")
+ fmt.Println(content)
+}
diff --git a/cmd/picoclaw/cmd_status.go b/cmd/picoclaw/cmd_status.go
new file mode 100644
index 000000000..07296784e
--- /dev/null
+++ b/cmd/picoclaw/cmd_status.go
@@ -0,0 +1,102 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/sipeed/picoclaw/pkg/auth"
+)
+
+func statusCmd() {
+ cfg, err := loadConfig()
+ if err != nil {
+ fmt.Printf("Error loading config: %v\n", err)
+ return
+ }
+
+ configPath := getConfigPath()
+
+ fmt.Printf("%s picoclaw Status\n", logo)
+ fmt.Printf("Version: %s\n", formatVersion())
+ build, _ := formatBuildInfo()
+ if build != "" {
+ fmt.Printf("Build: %s\n", build)
+ }
+ fmt.Println()
+
+ if _, err := os.Stat(configPath); err == nil {
+ fmt.Println("Config:", configPath, "✓")
+ } else {
+ fmt.Println("Config:", configPath, "✗")
+ }
+
+ workspace := cfg.WorkspacePath()
+ if _, err := os.Stat(workspace); err == nil {
+ fmt.Println("Workspace:", workspace, "✓")
+ } else {
+ fmt.Println("Workspace:", workspace, "✗")
+ }
+
+ if _, err := os.Stat(configPath); err == nil {
+ fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
+
+ hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
+ hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
+ hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
+ hasGemini := cfg.Providers.Gemini.APIKey != ""
+ hasZhipu := cfg.Providers.Zhipu.APIKey != ""
+ hasQwen := cfg.Providers.Qwen.APIKey != ""
+ hasGroq := cfg.Providers.Groq.APIKey != ""
+ hasVLLM := cfg.Providers.VLLM.APIBase != ""
+ hasMoonshot := cfg.Providers.Moonshot.APIKey != ""
+ hasDeepSeek := cfg.Providers.DeepSeek.APIKey != ""
+ hasVolcEngine := cfg.Providers.VolcEngine.APIKey != ""
+ hasNvidia := cfg.Providers.Nvidia.APIKey != ""
+ hasOllama := cfg.Providers.Ollama.APIBase != ""
+
+ status := func(enabled bool) string {
+ if enabled {
+ return "✓"
+ }
+ return "not set"
+ }
+ fmt.Println("OpenRouter API:", status(hasOpenRouter))
+ fmt.Println("Anthropic API:", status(hasAnthropic))
+ fmt.Println("OpenAI API:", status(hasOpenAI))
+ fmt.Println("Gemini API:", status(hasGemini))
+ fmt.Println("Zhipu API:", status(hasZhipu))
+ fmt.Println("Qwen API:", status(hasQwen))
+ fmt.Println("Groq API:", status(hasGroq))
+ fmt.Println("Moonshot API:", status(hasMoonshot))
+ fmt.Println("DeepSeek API:", status(hasDeepSeek))
+ fmt.Println("VolcEngine API:", status(hasVolcEngine))
+ fmt.Println("Nvidia API:", status(hasNvidia))
+ if hasVLLM {
+ fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase)
+ } else {
+ fmt.Println("vLLM/Local: not set")
+ }
+ if hasOllama {
+ fmt.Printf("Ollama: ✓ %s\n", cfg.Providers.Ollama.APIBase)
+ } else {
+ fmt.Println("Ollama: not set")
+ }
+
+ store, _ := auth.LoadStore()
+ if store != nil && len(store.Credentials) > 0 {
+ fmt.Println("\nOAuth/Token Auth:")
+ for provider, cred := range store.Credentials {
+ status := "authenticated"
+ if cred.IsExpired() {
+ status = "expired"
+ } else if cred.NeedsRefresh() {
+ status = "needs refresh"
+ }
+ fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
+ }
+ }
+ }
+}
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 2129662d7..ce9389417 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -7,41 +7,16 @@
package main
import (
- "bufio"
- "context"
- "embed"
"fmt"
"io"
- "io/fs"
"os"
- "os/signal"
"path/filepath"
"runtime"
- "strings"
- "time"
- "github.com/chzyer/readline"
- "github.com/sipeed/picoclaw/pkg/agent"
- "github.com/sipeed/picoclaw/pkg/auth"
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/cron"
- "github.com/sipeed/picoclaw/pkg/devices"
- "github.com/sipeed/picoclaw/pkg/heartbeat"
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/migrate"
- "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/skills"
- "github.com/sipeed/picoclaw/pkg/state"
- "github.com/sipeed/picoclaw/pkg/tools"
- "github.com/sipeed/picoclaw/pkg/voice"
)
-//go:generate cp -r ../../workspace .
-//go:embed workspace
-var embeddedFiles embed.FS
-
var (
version = "dev"
gitCommit string
@@ -214,1199 +189,11 @@ func printHelp() {
fmt.Println(" version Show version information")
}
-func onboard() {
- configPath := getConfigPath()
-
- if _, err := os.Stat(configPath); err == nil {
- fmt.Printf("Config already exists at %s\n", configPath)
- fmt.Print("Overwrite? (y/n): ")
- var response string
- fmt.Scanln(&response)
- if response != "y" {
- fmt.Println("Aborted.")
- return
- }
- }
-
- cfg := config.DefaultConfig()
- if err := config.SaveConfig(configPath, cfg); err != nil {
- fmt.Printf("Error saving config: %v\n", err)
- os.Exit(1)
- }
-
- workspace := cfg.WorkspacePath()
- createWorkspaceTemplates(workspace)
-
- fmt.Printf("%s picoclaw is ready!\n", logo)
- fmt.Println("\nNext steps:")
- fmt.Println(" 1. Add your API key to", configPath)
- fmt.Println(" Get one at: https://openrouter.ai/keys")
- fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
-}
-
-func copyEmbeddedToTarget(targetDir string) error {
- // Ensure target directory exists
- if err := os.MkdirAll(targetDir, 0755); err != nil {
- return fmt.Errorf("Failed to create target directory: %w", err)
- }
-
- // Walk through all files in embed.FS
- err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
- if err != nil {
- return err
- }
-
- // Skip directories
- if d.IsDir() {
- return nil
- }
-
- // Read embedded file
- data, err := embeddedFiles.ReadFile(path)
- if err != nil {
- return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
- }
-
- new_path, err := filepath.Rel("workspace", path)
- if err != nil {
- return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
- }
-
- // Build target file path
- targetPath := filepath.Join(targetDir, new_path)
-
- // Ensure target file's directory exists
- if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
- return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
- }
-
- // Write file
- if err := os.WriteFile(targetPath, data, 0644); err != nil {
- return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
- }
-
- return nil
- })
-
- return err
-}
-
-func createWorkspaceTemplates(workspace string) {
- err := copyEmbeddedToTarget(workspace)
- if err != nil {
- fmt.Printf("Error copying workspace templates: %v\n", err)
- }
-}
-
-func migrateCmd() {
- if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") {
- migrateHelp()
- return
- }
-
- opts := migrate.Options{}
-
- args := os.Args[2:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--dry-run":
- opts.DryRun = true
- case "--config-only":
- opts.ConfigOnly = true
- case "--workspace-only":
- opts.WorkspaceOnly = true
- case "--force":
- opts.Force = true
- case "--refresh":
- opts.Refresh = true
- case "--openclaw-home":
- if i+1 < len(args) {
- opts.OpenClawHome = args[i+1]
- i++
- }
- case "--picoclaw-home":
- if i+1 < len(args) {
- opts.PicoClawHome = args[i+1]
- i++
- }
- default:
- fmt.Printf("Unknown flag: %s\n", args[i])
- migrateHelp()
- os.Exit(1)
- }
- }
-
- result, err := migrate.Run(opts)
- if err != nil {
- fmt.Printf("Error: %v\n", err)
- os.Exit(1)
- }
-
- if !opts.DryRun {
- migrate.PrintSummary(result)
- }
-}
-
-func migrateHelp() {
- fmt.Println("\nMigrate from OpenClaw to PicoClaw")
- fmt.Println()
- fmt.Println("Usage: picoclaw migrate [options]")
- fmt.Println()
- fmt.Println("Options:")
- fmt.Println(" --dry-run Show what would be migrated without making changes")
- fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)")
- fmt.Println(" --config-only Only migrate config, skip workspace files")
- fmt.Println(" --workspace-only Only migrate workspace files, skip config")
- fmt.Println(" --force Skip confirmation prompts")
- fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)")
- fmt.Println(" --picoclaw-home Override PicoClaw home directory (default: ~/.picoclaw)")
- fmt.Println()
- fmt.Println("Examples:")
- fmt.Println(" picoclaw migrate Detect and migrate from OpenClaw")
- fmt.Println(" picoclaw migrate --dry-run Show what would be migrated")
- fmt.Println(" picoclaw migrate --refresh Re-sync workspace files")
- fmt.Println(" picoclaw migrate --force Migrate without confirmation")
-}
-
-func agentCmd() {
- message := ""
- sessionKey := "cli:default"
-
- args := os.Args[2:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--debug", "-d":
- logger.SetLevel(logger.DEBUG)
- fmt.Println("🔍 Debug mode enabled")
- case "-m", "--message":
- if i+1 < len(args) {
- message = args[i+1]
- i++
- }
- case "-s", "--session":
- if i+1 < len(args) {
- sessionKey = args[i+1]
- i++
- }
- }
- }
-
- cfg, err := loadConfig()
- if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- os.Exit(1)
- }
-
- provider, err := providers.CreateProvider(cfg)
- if err != nil {
- fmt.Printf("Error creating provider: %v\n", err)
- os.Exit(1)
- }
-
- msgBus := bus.NewMessageBus()
- agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
-
- // Print agent startup info (only for interactive mode)
- startupInfo := agentLoop.GetStartupInfo()
- logger.InfoCF("agent", "Agent initialized",
- map[string]interface{}{
- "tools_count": startupInfo["tools"].(map[string]interface{})["count"],
- "skills_total": startupInfo["skills"].(map[string]interface{})["total"],
- "skills_available": startupInfo["skills"].(map[string]interface{})["available"],
- })
-
- if message != "" {
- ctx := context.Background()
- response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
- if err != nil {
- fmt.Printf("Error: %v\n", err)
- os.Exit(1)
- }
- fmt.Printf("\n%s %s\n", logo, response)
- } else {
- fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo)
- interactiveMode(agentLoop, sessionKey)
- }
-}
-
-func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
- prompt := fmt.Sprintf("%s You: ", logo)
-
- rl, err := readline.NewEx(&readline.Config{
- Prompt: prompt,
- HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_history"),
- HistoryLimit: 100,
- InterruptPrompt: "^C",
- EOFPrompt: "exit",
- })
-
- if err != nil {
- fmt.Printf("Error initializing readline: %v\n", err)
- fmt.Println("Falling back to simple input mode...")
- simpleInteractiveMode(agentLoop, sessionKey)
- return
- }
- defer rl.Close()
-
- for {
- line, err := rl.Readline()
- if err != nil {
- if err == readline.ErrInterrupt || err == io.EOF {
- fmt.Println("\nGoodbye!")
- return
- }
- fmt.Printf("Error reading input: %v\n", err)
- continue
- }
-
- input := strings.TrimSpace(line)
- if input == "" {
- continue
- }
-
- if input == "exit" || input == "quit" {
- fmt.Println("Goodbye!")
- return
- }
-
- ctx := context.Background()
- response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
- if err != nil {
- fmt.Printf("Error: %v\n", err)
- continue
- }
-
- fmt.Printf("\n%s %s\n\n", logo, response)
- }
-}
-
-func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
- reader := bufio.NewReader(os.Stdin)
- for {
- fmt.Print(fmt.Sprintf("%s You: ", logo))
- line, err := reader.ReadString('\n')
- if err != nil {
- if err == io.EOF {
- fmt.Println("\nGoodbye!")
- return
- }
- fmt.Printf("Error reading input: %v\n", err)
- continue
- }
-
- input := strings.TrimSpace(line)
- if input == "" {
- continue
- }
-
- if input == "exit" || input == "quit" {
- fmt.Println("Goodbye!")
- return
- }
-
- ctx := context.Background()
- response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
- if err != nil {
- fmt.Printf("Error: %v\n", err)
- continue
- }
-
- fmt.Printf("\n%s %s\n\n", logo, response)
- }
-}
-
-func gatewayCmd() {
- // Check for --debug flag
- args := os.Args[2:]
- for _, arg := range args {
- if arg == "--debug" || arg == "-d" {
- logger.SetLevel(logger.DEBUG)
- fmt.Println("🔍 Debug mode enabled")
- break
- }
- }
-
- cfg, err := loadConfig()
- if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- os.Exit(1)
- }
-
- provider, err := providers.CreateProvider(cfg)
- if err != nil {
- fmt.Printf("Error creating provider: %v\n", err)
- os.Exit(1)
- }
-
- msgBus := bus.NewMessageBus()
- agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
-
- // Print agent startup info
- fmt.Println("\n📦 Agent Status:")
- startupInfo := agentLoop.GetStartupInfo()
- toolsInfo := startupInfo["tools"].(map[string]interface{})
- skillsInfo := startupInfo["skills"].(map[string]interface{})
- fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
- fmt.Printf(" • Skills: %d/%d available\n",
- skillsInfo["available"],
- skillsInfo["total"])
-
- // Log to file as well
- logger.InfoCF("agent", "Agent initialized",
- map[string]interface{}{
- "tools_count": toolsInfo["count"],
- "skills_total": skillsInfo["total"],
- "skills_available": skillsInfo["available"],
- })
-
- // Setup cron tool and service
- cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath())
-
- heartbeatService := heartbeat.NewHeartbeatService(
- cfg.WorkspacePath(),
- cfg.Heartbeat.Interval,
- cfg.Heartbeat.Enabled,
- )
- heartbeatService.SetBus(msgBus)
- heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
- // Use cli:direct as fallback if no valid channel
- if channel == "" || chatID == "" {
- channel, chatID = "cli", "direct"
- }
- // Use ProcessHeartbeat - no session history, each heartbeat is independent
- response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
- if err != nil {
- return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
- }
- if response == "HEARTBEAT_OK" {
- return tools.SilentResult("Heartbeat OK")
- }
- // For heartbeat, always return silent - the subagent result will be
- // sent to user via processSystemMessage when the async task completes
- return tools.SilentResult(response)
- })
-
- channelManager, err := channels.NewManager(cfg, msgBus)
- if err != nil {
- fmt.Printf("Error creating channel manager: %v\n", err)
- os.Exit(1)
- }
-
- var transcriber *voice.GroqTranscriber
- if cfg.Providers.Groq.APIKey != "" {
- transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey)
- logger.InfoC("voice", "Groq voice transcription enabled")
- }
-
- if transcriber != nil {
- if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
- if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
- tc.SetTranscriber(transcriber)
- logger.InfoC("voice", "Groq transcription attached to Telegram channel")
- }
- }
- if discordChannel, ok := channelManager.GetChannel("discord"); ok {
- if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
- dc.SetTranscriber(transcriber)
- logger.InfoC("voice", "Groq transcription attached to Discord channel")
- }
- }
- if slackChannel, ok := channelManager.GetChannel("slack"); ok {
- if sc, ok := slackChannel.(*channels.SlackChannel); ok {
- sc.SetTranscriber(transcriber)
- logger.InfoC("voice", "Groq transcription attached to Slack channel")
- }
- }
- }
-
- enabledChannels := channelManager.GetEnabledChannels()
- if len(enabledChannels) > 0 {
- fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
- } else {
- fmt.Println("⚠ Warning: No channels enabled")
- }
-
- fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
- fmt.Println("Press Ctrl+C to stop")
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- if err := cronService.Start(); err != nil {
- fmt.Printf("Error starting cron service: %v\n", err)
- }
- fmt.Println("✓ Cron service started")
-
- if err := heartbeatService.Start(); err != nil {
- fmt.Printf("Error starting heartbeat service: %v\n", err)
- }
- fmt.Println("✓ Heartbeat service started")
-
- stateManager := state.NewManager(cfg.WorkspacePath())
- deviceService := devices.NewService(devices.Config{
- Enabled: cfg.Devices.Enabled,
- MonitorUSB: cfg.Devices.MonitorUSB,
- }, stateManager)
- deviceService.SetBus(msgBus)
- if err := deviceService.Start(ctx); err != nil {
- fmt.Printf("Error starting device service: %v\n", err)
- } else if cfg.Devices.Enabled {
- fmt.Println("✓ Device event service started")
- }
-
- if err := channelManager.StartAll(ctx); err != nil {
- fmt.Printf("Error starting channels: %v\n", err)
- }
-
- go agentLoop.Run(ctx)
-
- sigChan := make(chan os.Signal, 1)
- signal.Notify(sigChan, os.Interrupt)
- <-sigChan
-
- fmt.Println("\nShutting down...")
- cancel()
- deviceService.Stop()
- heartbeatService.Stop()
- cronService.Stop()
- agentLoop.Stop()
- channelManager.StopAll(ctx)
- fmt.Println("✓ Gateway stopped")
-}
-
-func statusCmd() {
- cfg, err := loadConfig()
- if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- return
- }
-
- configPath := getConfigPath()
-
- fmt.Printf("%s picoclaw Status\n", logo)
- fmt.Printf("Version: %s\n", formatVersion())
- build, _ := formatBuildInfo()
- if build != "" {
- fmt.Printf("Build: %s\n", build)
- }
- fmt.Println()
-
- if _, err := os.Stat(configPath); err == nil {
- fmt.Println("Config:", configPath, "✓")
- } else {
- fmt.Println("Config:", configPath, "✗")
- }
-
- workspace := cfg.WorkspacePath()
- if _, err := os.Stat(workspace); err == nil {
- fmt.Println("Workspace:", workspace, "✓")
- } else {
- fmt.Println("Workspace:", workspace, "✗")
- }
-
- if _, err := os.Stat(configPath); err == nil {
- fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
-
- hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
- hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
- hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
- hasGemini := cfg.Providers.Gemini.APIKey != ""
- hasZhipu := cfg.Providers.Zhipu.APIKey != ""
- hasGroq := cfg.Providers.Groq.APIKey != ""
- hasVLLM := cfg.Providers.VLLM.APIBase != ""
-
- status := func(enabled bool) string {
- if enabled {
- return "✓"
- }
- return "not set"
- }
- fmt.Println("OpenRouter API:", status(hasOpenRouter))
- fmt.Println("Anthropic API:", status(hasAnthropic))
- fmt.Println("OpenAI API:", status(hasOpenAI))
- fmt.Println("Gemini API:", status(hasGemini))
- fmt.Println("Zhipu API:", status(hasZhipu))
- fmt.Println("Groq API:", status(hasGroq))
- if hasVLLM {
- fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase)
- } else {
- fmt.Println("vLLM/Local: not set")
- }
-
- store, _ := auth.LoadStore()
- if store != nil && len(store.Credentials) > 0 {
- fmt.Println("\nOAuth/Token Auth:")
- for provider, cred := range store.Credentials {
- status := "authenticated"
- if cred.IsExpired() {
- status = "expired"
- } else if cred.NeedsRefresh() {
- status = "needs refresh"
- }
- fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
- }
- }
- }
-}
-
-func authCmd() {
- if len(os.Args) < 3 {
- authHelp()
- return
- }
-
- switch os.Args[2] {
- case "login":
- authLoginCmd()
- case "logout":
- authLogoutCmd()
- case "status":
- authStatusCmd()
- default:
- fmt.Printf("Unknown auth command: %s\n", os.Args[2])
- authHelp()
- }
-}
-
-func authHelp() {
- fmt.Println("\nAuth commands:")
- fmt.Println(" login Login via OAuth or paste token")
- fmt.Println(" logout Remove stored credentials")
- fmt.Println(" status Show current auth status")
- fmt.Println()
- fmt.Println("Login options:")
- fmt.Println(" --provider Provider to login with (openai, anthropic)")
- fmt.Println(" --device-code Use device code flow (for headless environments)")
- fmt.Println()
- fmt.Println("Examples:")
- fmt.Println(" picoclaw auth login --provider openai")
- fmt.Println(" picoclaw auth login --provider openai --device-code")
- fmt.Println(" picoclaw auth login --provider anthropic")
- fmt.Println(" picoclaw auth logout --provider openai")
- fmt.Println(" picoclaw auth status")
-}
-
-func authLoginCmd() {
- provider := ""
- useDeviceCode := false
-
- args := os.Args[3:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--provider", "-p":
- if i+1 < len(args) {
- provider = args[i+1]
- i++
- }
- case "--device-code":
- useDeviceCode = true
- }
- }
-
- if provider == "" {
- fmt.Println("Error: --provider is required")
- fmt.Println("Supported providers: openai, anthropic")
- return
- }
-
- switch provider {
- case "openai":
- authLoginOpenAI(useDeviceCode)
- case "anthropic":
- authLoginPasteToken(provider)
- default:
- fmt.Printf("Unsupported provider: %s\n", provider)
- fmt.Println("Supported providers: openai, anthropic")
- }
-}
-
-func authLoginOpenAI(useDeviceCode bool) {
- cfg := auth.OpenAIOAuthConfig()
-
- var cred *auth.AuthCredential
- var err error
-
- if useDeviceCode {
- cred, err = auth.LoginDeviceCode(cfg)
- } else {
- cred, err = auth.LoginBrowser(cfg)
- }
-
- if err != nil {
- fmt.Printf("Login failed: %v\n", err)
- os.Exit(1)
- }
-
- if err := auth.SetCredential("openai", cred); err != nil {
- fmt.Printf("Failed to save credentials: %v\n", err)
- os.Exit(1)
- }
-
- appCfg, err := loadConfig()
- if err == nil {
- appCfg.Providers.OpenAI.AuthMethod = "oauth"
- if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
- fmt.Printf("Warning: could not update config: %v\n", err)
- }
- }
-
- fmt.Println("Login successful!")
- if cred.AccountID != "" {
- fmt.Printf("Account: %s\n", cred.AccountID)
- }
-}
-
-func authLoginPasteToken(provider string) {
- cred, err := auth.LoginPasteToken(provider, os.Stdin)
- if err != nil {
- fmt.Printf("Login failed: %v\n", err)
- os.Exit(1)
- }
-
- if err := auth.SetCredential(provider, cred); err != nil {
- fmt.Printf("Failed to save credentials: %v\n", err)
- os.Exit(1)
- }
-
- appCfg, err := loadConfig()
- if err == nil {
- switch provider {
- case "anthropic":
- appCfg.Providers.Anthropic.AuthMethod = "token"
- case "openai":
- appCfg.Providers.OpenAI.AuthMethod = "token"
- }
- if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
- fmt.Printf("Warning: could not update config: %v\n", err)
- }
- }
-
- fmt.Printf("Token saved for %s!\n", provider)
-}
-
-func authLogoutCmd() {
- provider := ""
-
- args := os.Args[3:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--provider", "-p":
- if i+1 < len(args) {
- provider = args[i+1]
- i++
- }
- }
- }
-
- if provider != "" {
- if err := auth.DeleteCredential(provider); err != nil {
- fmt.Printf("Failed to remove credentials: %v\n", err)
- os.Exit(1)
- }
-
- appCfg, err := loadConfig()
- if err == nil {
- switch provider {
- case "openai":
- appCfg.Providers.OpenAI.AuthMethod = ""
- case "anthropic":
- appCfg.Providers.Anthropic.AuthMethod = ""
- }
- config.SaveConfig(getConfigPath(), appCfg)
- }
-
- fmt.Printf("Logged out from %s\n", provider)
- } else {
- if err := auth.DeleteAllCredentials(); err != nil {
- fmt.Printf("Failed to remove credentials: %v\n", err)
- os.Exit(1)
- }
-
- appCfg, err := loadConfig()
- if err == nil {
- appCfg.Providers.OpenAI.AuthMethod = ""
- appCfg.Providers.Anthropic.AuthMethod = ""
- config.SaveConfig(getConfigPath(), appCfg)
- }
-
- fmt.Println("Logged out from all providers")
- }
-}
-
-func authStatusCmd() {
- store, err := auth.LoadStore()
- if err != nil {
- fmt.Printf("Error loading auth store: %v\n", err)
- return
- }
-
- if len(store.Credentials) == 0 {
- fmt.Println("No authenticated providers.")
- fmt.Println("Run: picoclaw auth login --provider ")
- return
- }
-
- fmt.Println("\nAuthenticated Providers:")
- fmt.Println("------------------------")
- for provider, cred := range store.Credentials {
- status := "active"
- if cred.IsExpired() {
- status = "expired"
- } else if cred.NeedsRefresh() {
- status = "needs refresh"
- }
-
- fmt.Printf(" %s:\n", provider)
- fmt.Printf(" Method: %s\n", cred.AuthMethod)
- fmt.Printf(" Status: %s\n", status)
- if cred.AccountID != "" {
- fmt.Printf(" Account: %s\n", cred.AccountID)
- }
- if !cred.ExpiresAt.IsZero() {
- fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
- }
- }
-}
-
func getConfigPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".picoclaw", "config.json")
}
-func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string) *cron.CronService {
- cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
-
- // Create cron service
- cronService := cron.NewCronService(cronStorePath, nil)
-
- // Create and register CronTool
- cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace)
- agentLoop.RegisterTool(cronTool)
-
- // Set the onJob handler
- cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
- result := cronTool.ExecuteJob(context.Background(), job)
- return result, nil
- })
-
- return cronService
-}
-
func loadConfig() (*config.Config, error) {
return config.LoadConfig(getConfigPath())
}
-
-func cronCmd() {
- if len(os.Args) < 3 {
- cronHelp()
- return
- }
-
- subcommand := os.Args[2]
-
- // Load config to get workspace path
- cfg, err := loadConfig()
- if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- return
- }
-
- cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
-
- switch subcommand {
- case "list":
- cronListCmd(cronStorePath)
- case "add":
- cronAddCmd(cronStorePath)
- case "remove":
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw cron remove ")
- return
- }
- cronRemoveCmd(cronStorePath, os.Args[3])
- case "enable":
- cronEnableCmd(cronStorePath, false)
- case "disable":
- cronEnableCmd(cronStorePath, true)
- default:
- fmt.Printf("Unknown cron command: %s\n", subcommand)
- cronHelp()
- }
-}
-
-func cronHelp() {
- fmt.Println("\nCron commands:")
- fmt.Println(" list List all scheduled jobs")
- fmt.Println(" add Add a new scheduled job")
- fmt.Println(" remove Remove a job by ID")
- fmt.Println(" enable Enable a job")
- fmt.Println(" disable Disable a job")
- fmt.Println()
- fmt.Println("Add options:")
- fmt.Println(" -n, --name Job name")
- fmt.Println(" -m, --message Message for agent")
- fmt.Println(" -e, --every Run every N seconds")
- fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')")
- fmt.Println(" -d, --deliver Deliver response to channel")
- fmt.Println(" --to Recipient for delivery")
- fmt.Println(" --channel Channel for delivery")
-}
-
-func cronListCmd(storePath string) {
- cs := cron.NewCronService(storePath, nil)
- jobs := cs.ListJobs(true) // Show all jobs, including disabled
-
- if len(jobs) == 0 {
- fmt.Println("No scheduled jobs.")
- return
- }
-
- fmt.Println("\nScheduled Jobs:")
- fmt.Println("----------------")
- for _, job := range jobs {
- var schedule string
- if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
- schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
- } else if job.Schedule.Kind == "cron" {
- schedule = job.Schedule.Expr
- } else {
- schedule = "one-time"
- }
-
- nextRun := "scheduled"
- if job.State.NextRunAtMS != nil {
- nextTime := time.UnixMilli(*job.State.NextRunAtMS)
- nextRun = nextTime.Format("2006-01-02 15:04")
- }
-
- status := "enabled"
- if !job.Enabled {
- status = "disabled"
- }
-
- fmt.Printf(" %s (%s)\n", job.Name, job.ID)
- fmt.Printf(" Schedule: %s\n", schedule)
- fmt.Printf(" Status: %s\n", status)
- fmt.Printf(" Next run: %s\n", nextRun)
- }
-}
-
-func cronAddCmd(storePath string) {
- name := ""
- message := ""
- var everySec *int64
- cronExpr := ""
- deliver := false
- channel := ""
- to := ""
-
- args := os.Args[3:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "-n", "--name":
- if i+1 < len(args) {
- name = args[i+1]
- i++
- }
- case "-m", "--message":
- if i+1 < len(args) {
- message = args[i+1]
- i++
- }
- case "-e", "--every":
- if i+1 < len(args) {
- var sec int64
- fmt.Sscanf(args[i+1], "%d", &sec)
- everySec = &sec
- i++
- }
- case "-c", "--cron":
- if i+1 < len(args) {
- cronExpr = args[i+1]
- i++
- }
- case "-d", "--deliver":
- deliver = true
- case "--to":
- if i+1 < len(args) {
- to = args[i+1]
- i++
- }
- case "--channel":
- if i+1 < len(args) {
- channel = args[i+1]
- i++
- }
- }
- }
-
- if name == "" {
- fmt.Println("Error: --name is required")
- return
- }
-
- if message == "" {
- fmt.Println("Error: --message is required")
- return
- }
-
- if everySec == nil && cronExpr == "" {
- fmt.Println("Error: Either --every or --cron must be specified")
- return
- }
-
- var schedule cron.CronSchedule
- if everySec != nil {
- everyMS := *everySec * 1000
- schedule = cron.CronSchedule{
- Kind: "every",
- EveryMS: &everyMS,
- }
- } else {
- schedule = cron.CronSchedule{
- Kind: "cron",
- Expr: cronExpr,
- }
- }
-
- cs := cron.NewCronService(storePath, nil)
- job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
- if err != nil {
- fmt.Printf("Error adding job: %v\n", err)
- return
- }
-
- fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
-}
-
-func cronRemoveCmd(storePath, jobID string) {
- cs := cron.NewCronService(storePath, nil)
- if cs.RemoveJob(jobID) {
- fmt.Printf("✓ Removed job %s\n", jobID)
- } else {
- fmt.Printf("✗ Job %s not found\n", jobID)
- }
-}
-
-func cronEnableCmd(storePath string, disable bool) {
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw cron enable/disable ")
- return
- }
-
- jobID := os.Args[3]
- cs := cron.NewCronService(storePath, nil)
- enabled := !disable
-
- job := cs.EnableJob(jobID, enabled)
- if job != nil {
- status := "enabled"
- if disable {
- status = "disabled"
- }
- fmt.Printf("✓ Job '%s' %s\n", job.Name, status)
- } else {
- fmt.Printf("✗ Job %s not found\n", jobID)
- }
-}
-
-func skillsHelp() {
- fmt.Println("\nSkills commands:")
- fmt.Println(" list List installed skills")
- fmt.Println(" install Install skill from GitHub")
- fmt.Println(" install-builtin Install all builtin skills to workspace")
- fmt.Println(" list-builtin List available builtin skills")
- fmt.Println(" remove Remove installed skill")
- fmt.Println(" search Search available skills")
- fmt.Println(" show Show skill details")
- fmt.Println()
- fmt.Println("Examples:")
- fmt.Println(" picoclaw skills list")
- fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
- fmt.Println(" picoclaw skills install-builtin")
- fmt.Println(" picoclaw skills list-builtin")
- fmt.Println(" picoclaw skills remove weather")
-}
-
-func skillsListCmd(loader *skills.SkillsLoader) {
- allSkills := loader.ListSkills()
-
- if len(allSkills) == 0 {
- fmt.Println("No skills installed.")
- return
- }
-
- fmt.Println("\nInstalled Skills:")
- fmt.Println("------------------")
- for _, skill := range allSkills {
- fmt.Printf(" ✓ %s (%s)\n", skill.Name, skill.Source)
- if skill.Description != "" {
- fmt.Printf(" %s\n", skill.Description)
- }
- }
-}
-
-func skillsInstallCmd(installer *skills.SkillInstaller) {
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw skills install ")
- fmt.Println("Example: picoclaw skills install sipeed/picoclaw-skills/weather")
- return
- }
-
- repo := os.Args[3]
- fmt.Printf("Installing skill from %s...\n", repo)
-
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
-
- if err := installer.InstallFromGitHub(ctx, repo); err != nil {
- fmt.Printf("✗ Failed to install skill: %v\n", err)
- os.Exit(1)
- }
-
- fmt.Printf("✓ Skill '%s' installed successfully!\n", filepath.Base(repo))
-}
-
-func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
- fmt.Printf("Removing skill '%s'...\n", skillName)
-
- if err := installer.Uninstall(skillName); err != nil {
- fmt.Printf("✗ Failed to remove skill: %v\n", err)
- os.Exit(1)
- }
-
- fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
-}
-
-func skillsInstallBuiltinCmd(workspace string) {
- builtinSkillsDir := "./picoclaw/skills"
- workspaceSkillsDir := filepath.Join(workspace, "skills")
-
- fmt.Printf("Copying builtin skills to workspace...\n")
-
- skillsToInstall := []string{
- "weather",
- "news",
- "stock",
- "calculator",
- }
-
- for _, skillName := range skillsToInstall {
- builtinPath := filepath.Join(builtinSkillsDir, skillName)
- workspacePath := filepath.Join(workspaceSkillsDir, skillName)
-
- if _, err := os.Stat(builtinPath); err != nil {
- fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err)
- continue
- }
-
- if err := os.MkdirAll(workspacePath, 0755); err != nil {
- fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
- continue
- }
-
- if err := copyDirectory(builtinPath, workspacePath); err != nil {
- fmt.Printf("✗ Failed to copy %s: %v\n", skillName, err)
- }
- }
-
- fmt.Println("\n✓ All builtin skills installed!")
- fmt.Println("Now you can use them in your workspace.")
-}
-
-func skillsListBuiltinCmd() {
- cfg, err := loadConfig()
- if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- return
- }
- builtinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), "picoclaw", "skills")
-
- fmt.Println("\nAvailable Builtin Skills:")
- fmt.Println("-----------------------")
-
- entries, err := os.ReadDir(builtinSkillsDir)
- if err != nil {
- fmt.Printf("Error reading builtin skills: %v\n", err)
- return
- }
-
- if len(entries) == 0 {
- fmt.Println("No builtin skills available.")
- return
- }
-
- for _, entry := range entries {
- if entry.IsDir() {
- skillName := entry.Name()
- skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md")
-
- description := "No description"
- if _, err := os.Stat(skillFile); err == nil {
- data, err := os.ReadFile(skillFile)
- if err == nil {
- content := string(data)
- if idx := strings.Index(content, "\n"); idx > 0 {
- firstLine := content[:idx]
- if strings.Contains(firstLine, "description:") {
- descLine := strings.Index(content[idx:], "\n")
- if descLine > 0 {
- description = strings.TrimSpace(content[idx+descLine : idx+descLine])
- }
- }
- }
- }
- }
- status := "✓"
- fmt.Printf(" %s %s\n", status, entry.Name())
- if description != "" {
- fmt.Printf(" %s\n", description)
- }
- }
- }
-}
-
-func skillsSearchCmd(installer *skills.SkillInstaller) {
- fmt.Println("Searching for available skills...")
-
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
-
- availableSkills, err := installer.ListAvailableSkills(ctx)
- if err != nil {
- fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
- return
- }
-
- if len(availableSkills) == 0 {
- fmt.Println("No skills available.")
- return
- }
-
- fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills))
- fmt.Println("--------------------")
- for _, skill := range availableSkills {
- fmt.Printf(" 📦 %s\n", skill.Name)
- fmt.Printf(" %s\n", skill.Description)
- fmt.Printf(" Repo: %s\n", skill.Repository)
- if skill.Author != "" {
- fmt.Printf(" Author: %s\n", skill.Author)
- }
- if len(skill.Tags) > 0 {
- fmt.Printf(" Tags: %v\n", skill.Tags)
- }
- fmt.Println()
- }
-}
-
-func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
- content, ok := loader.LoadSkill(skillName)
- if !ok {
- fmt.Printf("✗ Skill '%s' not found\n", skillName)
- return
- }
-
- fmt.Printf("\n📦 Skill: %s\n", skillName)
- fmt.Println("----------------------")
- fmt.Println(content)
-}
diff --git a/config/config.example.json b/config/config.example.json
index 1a20c5292..ad0fb1afd 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -9,18 +9,62 @@
"max_tool_iterations": 20
}
},
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key",
+ "api_base": "https://api.openai.com/v1"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key",
+ "api_base": "https://api.anthropic.com/v1"
+ },
+ {
+ "model_name": "gemini",
+ "model": "antigravity/gemini-2.0-flash",
+ "auth_method": "oauth"
+ },
+ {
+ "model_name": "deepseek",
+ "model": "deepseek/deepseek-chat",
+ "api_key": "sk-your-deepseek-key"
+ },
+ {
+ "model_name": "loadbalanced-gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-key1",
+ "api_base": "https://api1.example.com/v1"
+ },
+ {
+ "model_name": "loadbalanced-gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-key2",
+ "api_base": "https://api2.example.com/v1"
+ }
+ ],
"channels": {
"telegram": {
"enabled": false,
"token": "YOUR_TELEGRAM_BOT_TOKEN",
"proxy": "",
- "allow_from": ["YOUR_USER_ID"]
+ "allow_from": [
+ "YOUR_USER_ID"
+ ]
},
"discord": {
"enabled": false,
"token": "YOUR_DISCORD_BOT_TOKEN",
"allow_from": []
},
+ "qq": {
+ "enabled": false,
+ "app_id": "YOUR_QQ_APP_ID",
+ "app_secret": "YOUR_QQ_APP_SECRET",
+ "allow_from": []
+ },
"maixcam": {
"enabled": false,
"host": "0.0.0.0",
@@ -71,13 +115,15 @@
}
},
"providers": {
+ "_comment": "DEPRECATED: Use model_list instead. This will be removed in a future version",
"anthropic": {
"api_key": "",
"api_base": ""
},
"openai": {
"api_key": "",
- "api_base": ""
+ "api_base": "",
+ "web_search": true
},
"openrouter": {
"api_key": "sk-or-v1-xxx",
@@ -107,14 +153,47 @@
"moonshot": {
"api_key": "sk-xxx",
"api_base": ""
+ },
+ "qwen": {
+ "api_key": "sk-xxx",
+ "api_base": ""
+ },
+ "ollama": {
+ "api_key": "",
+ "api_base": "http://localhost:11434/v1"
+ },
+ "cerebras": {
+ "api_key": "",
+ "api_base": ""
+ },
+ "volcengine": {
+ "api_key": "",
+ "api_base": ""
}
},
"tools": {
"web": {
- "search": {
+ "brave": {
+ "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ },
+ "perplexity": {
+ "enabled": false,
+ "api_key": "pplx-xxx",
+ "max_results": 5
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
+ },
+ "exec": {
+ "enable_deny_patterns": false,
+ "custom_deny_patterns": []
}
},
"heartbeat": {
diff --git a/docker-compose.yml b/docker-compose.yml
index 48769627c..32e8ee339 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,8 +11,8 @@ services:
profiles:
- agent
volumes:
- - ./config/config.json:/root/.picoclaw/config.json:ro
- - picoclaw-workspace:/root/.picoclaw/workspace
+ - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
+ - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
entrypoint: ["picoclaw", "agent"]
stdin_open: true
tty: true
@@ -31,9 +31,9 @@ services:
- gateway
volumes:
# Configuration file
- - ./config/config.json:/root/.picoclaw/config.json:ro
+ - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
# Persistent workspace (sessions, memory, logs)
- - picoclaw-workspace:/root/.picoclaw/workspace
+ - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
command: ["gateway"]
volumes:
diff --git a/docs/ANTIGRAVITY_AUTH.md b/docs/ANTIGRAVITY_AUTH.md
new file mode 100644
index 000000000..5d68de427
--- /dev/null
+++ b/docs/ANTIGRAVITY_AUTH.md
@@ -0,0 +1,1002 @@
+# Antigravity Authentication & Integration Guide
+
+## Overview
+
+**Antigravity** (Google Cloud Code Assist) is a Google-backed AI model provider that offers access to models like Claude Opus 4.6 and Gemini through Google's Cloud infrastructure. This document provides a complete guide on how authentication works, how to fetch models, and how to implement a new provider in PicoClaw.
+
+---
+
+## Table of Contents
+
+1. [Authentication Flow](#authentication-flow)
+2. [OAuth Implementation Details](#oauth-implementation-details)
+3. [Token Management](#token-management)
+4. [Models List Fetching](#models-list-fetching)
+5. [Usage Tracking](#usage-tracking)
+6. [Provider Plugin Structure](#provider-plugin-structure)
+7. [Integration Requirements](#integration-requirements)
+8. [API Endpoints](#api-endpoints)
+9. [Configuration](#configuration)
+10. [Creating a New Provider in PicoClaw](#creating-a-new-provider-in-picoclaw)
+
+---
+
+## Authentication Flow
+
+### 1. OAuth 2.0 with PKCE
+
+Antigravity uses **OAuth 2.0 with PKCE (Proof Key for Code Exchange)** for secure authentication:
+
+```
+┌─────────────┐ ┌─────────────────┐
+│ Client │ ───(1) Generate PKCE Pair────────> │ │
+│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │
+│ │ │ Server │
+│ │ <──(3) Redirect with Code───────── │ │
+│ │ └─────────────────┘
+│ │ ───(4) Exchange Code for Tokens──> │ Token URL │
+│ │ │ │
+│ │ <──(5) Access + Refresh Tokens──── │ │
+└─────────────┘ └─────────────────┘
+```
+
+### 2. Detailed Steps
+
+#### Step 1: Generate PKCE Parameters
+```typescript
+function generatePkce(): { verifier: string; challenge: string } {
+ const verifier = randomBytes(32).toString("hex");
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
+ return { verifier, challenge };
+}
+```
+
+#### Step 2: Build Authorization URL
+```typescript
+const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
+const REDIRECT_URI = "http://localhost:51121/oauth-callback";
+
+function buildAuthUrl(params: { challenge: string; state: string }): string {
+ const url = new URL(AUTH_URL);
+ url.searchParams.set("client_id", CLIENT_ID);
+ url.searchParams.set("response_type", "code");
+ url.searchParams.set("redirect_uri", REDIRECT_URI);
+ url.searchParams.set("scope", SCOPES.join(" "));
+ url.searchParams.set("code_challenge", params.challenge);
+ url.searchParams.set("code_challenge_method", "S256");
+ url.searchParams.set("state", params.state);
+ url.searchParams.set("access_type", "offline");
+ url.searchParams.set("prompt", "consent");
+ return url.toString();
+}
+```
+
+**Required Scopes:**
+```typescript
+const SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ "https://www.googleapis.com/auth/cclog",
+ "https://www.googleapis.com/auth/experimentsandconfigs",
+];
+```
+
+#### Step 3: Handle OAuth Callback
+
+**Automatic Mode (Local Development):**
+- Start a local HTTP server on port 51121
+- Wait for the redirect from Google
+- Extract the authorization code from the query parameters
+
+**Manual Mode (Remote/Headless):**
+- Display the authorization URL to the user
+- User completes authentication in their browser
+- User pastes the full redirect URL back into the terminal
+- Parse the code from the pasted URL
+
+#### Step 4: Exchange Code for Tokens
+```typescript
+const TOKEN_URL = "https://oauth2.googleapis.com/token";
+
+async function exchangeCode(params: {
+ code: string;
+ verifier: string;
+}): Promise<{ access: string; refresh: string; expires: number }> {
+ const response = await fetch(TOKEN_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ client_id: CLIENT_ID,
+ client_secret: CLIENT_SECRET,
+ code: params.code,
+ grant_type: "authorization_code",
+ redirect_uri: REDIRECT_URI,
+ code_verifier: params.verifier,
+ }),
+ });
+
+ const data = await response.json();
+
+ return {
+ access: data.access_token,
+ refresh: data.refresh_token,
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer
+ };
+}
+```
+
+#### Step 5: Fetch Additional User Data
+
+**User Email:**
+```typescript
+async function fetchUserEmail(accessToken: string): Promise {
+ const response = await fetch(
+ "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
+ { headers: { Authorization: `Bearer ${accessToken}` } }
+ );
+ const data = await response.json();
+ return data.email;
+}
+```
+
+**Project ID (Required for API calls):**
+```typescript
+async function fetchProjectId(accessToken: string): Promise {
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "google-api-nodejs-client/9.15.1",
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+ "Client-Metadata": JSON.stringify({
+ ideType: "IDE_UNSPECIFIED",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ }),
+ };
+
+ const response = await fetch(
+ "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ metadata: {
+ ideType: "IDE_UNSPECIFIED",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ },
+ }),
+ }
+ );
+
+ const data = await response.json();
+ return data.cloudaicompanionProject || "rising-fact-p41fc"; // Default fallback
+}
+```
+
+---
+
+## OAuth Implementation Details
+
+### Client Credentials
+
+**Important:** These are base64-encoded in the source code for sync with pi-ai:
+
+```typescript
+const decode = (s: string) => Buffer.from(s, "base64").toString();
+
+const CLIENT_ID = decode(
+ "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ=="
+);
+const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
+```
+
+### OAuth Flow Modes
+
+1. **Automatic Flow** (Local machines with browser):
+ - Opens browser automatically
+ - Local callback server captures redirect
+ - No user interaction required after initial auth
+
+2. **Manual Flow** (Remote/headless/WSL2):
+ - URL displayed for manual copy-paste
+ - User completes auth in external browser
+ - User pastes full redirect URL back
+
+```typescript
+function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
+ return isRemote || isWSL2Sync();
+}
+```
+
+---
+
+## Token Management
+
+### Auth Profile Structure
+
+```typescript
+type OAuthCredential = {
+ type: "oauth";
+ provider: "google-antigravity";
+ access: string; // Access token
+ refresh: string; // Refresh token
+ expires: number; // Expiration timestamp (ms since epoch)
+ email?: string; // User email
+ projectId?: string; // Google Cloud project ID
+};
+```
+
+### Token Refresh
+
+The credential includes a refresh token that can be used to obtain new access tokens when the current one expires. The expiration is set with a 5-minute buffer to prevent race conditions.
+
+---
+
+## Models List Fetching
+
+### Fetch Available Models
+
+```typescript
+const BASE_URL = "https://cloudcode-pa.googleapis.com";
+
+async function fetchAvailableModels(
+ accessToken: string,
+ projectId: string
+): Promise {
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "antigravity",
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+ };
+
+ const response = await fetch(
+ `${BASE_URL}/v1internal:fetchAvailableModels`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ project: projectId }),
+ }
+ );
+
+ const data = await response.json();
+
+ // Returns models with quota information
+ return Object.entries(data.models).map(([modelId, modelInfo]) => ({
+ id: modelId,
+ displayName: modelInfo.displayName,
+ quotaInfo: {
+ remainingFraction: modelInfo.quotaInfo?.remainingFraction,
+ resetTime: modelInfo.quotaInfo?.resetTime,
+ isExhausted: modelInfo.quotaInfo?.isExhausted,
+ },
+ }));
+}
+```
+
+### Response Format
+
+```typescript
+type FetchAvailableModelsResponse = {
+ models?: Record;
+};
+```
+
+---
+
+## Usage Tracking
+
+### Fetch Usage Data
+
+```typescript
+export async function fetchAntigravityUsage(
+ token: string,
+ timeoutMs: number
+): Promise {
+ // 1. Fetch credits and plan info
+ const loadCodeAssistRes = await fetch(
+ `${BASE_URL}/v1internal:loadCodeAssist`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ metadata: {
+ ideType: "ANTIGRAVITY",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ },
+ }),
+ }
+ );
+
+ // Extract credits info
+ const { availablePromptCredits, planInfo, currentTier } = data;
+
+ // 2. Fetch model quotas
+ const modelsRes = await fetch(
+ `${BASE_URL}/v1internal:fetchAvailableModels`,
+ {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ body: JSON.stringify({ project: projectId }),
+ }
+ );
+
+ // Build usage windows
+ return {
+ provider: "google-antigravity",
+ displayName: "Google Antigravity",
+ windows: [
+ { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) },
+ // Individual model quotas...
+ ],
+ plan: currentTier?.name || planType,
+ };
+}
+```
+
+### Usage Response Structure
+
+```typescript
+type ProviderUsageSnapshot = {
+ provider: "google-antigravity";
+ displayName: string;
+ windows: UsageWindow[];
+ plan?: string;
+ error?: string;
+};
+
+type UsageWindow = {
+ label: string; // "Credits" or model ID
+ usedPercent: number; // 0-100
+ resetAt?: number; // Timestamp when quota resets
+};
+```
+
+---
+
+## Provider Plugin Structure
+
+### Plugin Definition
+
+```typescript
+const antigravityPlugin = {
+ id: "google-antigravity-auth",
+ name: "Google Antigravity Auth",
+ description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
+ configSchema: emptyPluginConfigSchema(),
+
+ register(api: OpenClawPluginApi) {
+ api.registerProvider({
+ id: "google-antigravity",
+ label: "Google Antigravity",
+ docsPath: "/providers/models",
+ aliases: ["antigravity"],
+
+ auth: [
+ {
+ id: "oauth",
+ label: "Google OAuth",
+ hint: "PKCE + localhost callback",
+ kind: "oauth",
+ run: async (ctx: ProviderAuthContext) => {
+ // OAuth implementation here
+ },
+ },
+ ],
+ });
+ },
+};
+```
+
+### ProviderAuthContext
+
+```typescript
+type ProviderAuthContext = {
+ config: OpenClawConfig;
+ agentDir?: string;
+ workspaceDir?: string;
+ prompter: WizardPrompter; // UI prompts/notifications
+ runtime: RuntimeEnv; // Logging, etc.
+ isRemote: boolean; // Whether running remotely
+ openUrl: (url: string) => Promise; // Browser opener
+ oauth: {
+ createVpsAwareHandlers: Function;
+ };
+};
+```
+
+### ProviderAuthResult
+
+```typescript
+type ProviderAuthResult = {
+ profiles: Array<{
+ profileId: string;
+ credential: AuthProfileCredential;
+ }>;
+ configPatch?: Partial;
+ defaultModel?: string;
+ notes?: string[];
+};
+```
+
+---
+
+## Integration Requirements
+
+### 1. Required Environment/Dependencies
+
+- Node.js ≥ 22
+- OpenClaw plugin-sdk
+- crypto module (built-in)
+- http module (built-in)
+
+### 2. Required Headers for API Calls
+
+```typescript
+const REQUIRED_HEADERS = {
+ "Authorization": `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "antigravity", // or "google-api-nodejs-client/9.15.1"
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+};
+
+// For loadCodeAssist calls, also include:
+const CLIENT_METADATA = {
+ ideType: "ANTIGRAVITY", // or "IDE_UNSPECIFIED"
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+};
+```
+
+### 3. Model Schema Sanitization
+
+Antigravity uses Gemini-compatible models, so tool schemas must be sanitized:
+
+```typescript
+const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
+ "patternProperties",
+ "additionalProperties",
+ "$schema",
+ "$id",
+ "$ref",
+ "$defs",
+ "definitions",
+ "examples",
+ "minLength",
+ "maxLength",
+ "minimum",
+ "maximum",
+ "multipleOf",
+ "pattern",
+ "format",
+ "minItems",
+ "maxItems",
+ "uniqueItems",
+ "minProperties",
+ "maxProperties",
+]);
+
+// Clean schema before sending
+function cleanToolSchemaForGemini(schema: Record): unknown {
+ // Remove unsupported keywords
+ // Ensure top-level has type: "object"
+ // Flatten anyOf/oneOf unions
+}
+```
+
+### 4. Thinking Block Handling (Claude Models)
+
+For Antigravity Claude models, thinking blocks require special handling:
+
+```typescript
+const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
+
+export function sanitizeAntigravityThinkingBlocks(
+ messages: AgentMessage[]
+): AgentMessage[] {
+ // Validate thinking signatures
+ // Normalize signature fields
+ // Discard unsigned thinking blocks
+}
+```
+
+---
+
+## API Endpoints
+
+### Authentication Endpoints
+
+| Endpoint | Method | Purpose |
+|----------|--------|---------|
+| `https://accounts.google.com/o/oauth2/v2/auth` | GET | OAuth authorization |
+| `https://oauth2.googleapis.com/token` | POST | Token exchange |
+| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | User info (email) |
+
+### Cloud Code Assist Endpoints
+
+| Endpoint | Method | Purpose |
+|----------|--------|---------|
+| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | Load project info, credits, plan |
+| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | List available models with quotas |
+| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | Chat streaming endpoint |
+
+**API Request Format (Chat):**
+The `v1internal:streamGenerateContent` endpoint expects an envelope wrapping the standard Gemini request:
+
+```json
+{
+ "project": "your-project-id",
+ "model": "model-id",
+ "request": {
+ "contents": [...],
+ "systemInstruction": {...},
+ "generationConfig": {...},
+ "tools": [...]
+ },
+ "requestType": "agent",
+ "userAgent": "antigravity",
+ "requestId": "agent-timestamp-random"
+}
+```
+
+**API Response Format (SSE):**
+Each SSE message (`data: {...}`) is wrapped in a `response` field:
+
+```json
+{
+ "response": {
+ "candidates": [...],
+ "usageMetadata": {...},
+ "modelVersion": "...",
+ "responseId": "..."
+ },
+ "traceId": "...",
+ "metadata": {}
+}
+```
+
+---
+
+## Configuration
+
+### openclaw.json Configuration
+
+```json5
+{
+ agents: {
+ defaults: {
+ model: {
+ primary: "google-antigravity/claude-opus-4-6-thinking",
+ },
+ },
+ },
+}
+```
+
+### Auth Profile Storage
+
+Auth profiles are stored in `~/.openclaw/agent/auth-profiles.json`:
+
+```json
+{
+ "version": 1,
+ "profiles": {
+ "google-antigravity:user@example.com": {
+ "type": "oauth",
+ "provider": "google-antigravity",
+ "access": "ya29...",
+ "refresh": "1//...",
+ "expires": 1704067200000,
+ "email": "user@example.com",
+ "projectId": "my-project-id"
+ }
+ }
+}
+```
+
+---
+
+## Creating a New Provider in PicoClaw
+
+### Step-by-Step Implementation
+
+#### 1. Create Plugin Structure
+
+```
+extensions/
+└── your-provider-auth/
+ ├── openclaw.plugin.json
+ ├── package.json
+ ├── README.md
+ └── index.ts
+```
+
+#### 2. Define Plugin Manifest
+
+**openclaw.plugin.json:**
+```json
+{
+ "id": "your-provider-auth",
+ "providers": ["your-provider"],
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {}
+ }
+}
+```
+
+**package.json:**
+```json
+{
+ "name": "@openclaw/your-provider-auth",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Your Provider OAuth plugin",
+ "type": "module"
+}
+```
+
+#### 3. Implement OAuth Flow
+
+```typescript
+import {
+ buildOauthProviderAuthResult,
+ emptyPluginConfigSchema,
+ type OpenClawPluginApi,
+ type ProviderAuthContext,
+} from "openclaw/plugin-sdk";
+
+const YOUR_CLIENT_ID = "your-client-id";
+const YOUR_CLIENT_SECRET = "your-client-secret";
+const AUTH_URL = "https://provider.com/oauth/authorize";
+const TOKEN_URL = "https://provider.com/oauth/token";
+const REDIRECT_URI = "http://localhost:PORT/oauth-callback";
+
+async function loginYourProvider(params: {
+ isRemote: boolean;
+ openUrl: (url: string) => Promise;
+ prompt: (message: string) => Promise;
+ note: (message: string, title?: string) => Promise;
+ log: (message: string) => void;
+ progress: { update: (msg: string) => void; stop: (msg?: string) => void };
+}) {
+ // 1. Generate PKCE
+ const { verifier, challenge } = generatePkce();
+ const state = randomBytes(16).toString("hex");
+
+ // 2. Build auth URL
+ const authUrl = buildAuthUrl({ challenge, state });
+
+ // 3. Start callback server (if not remote)
+ const callbackServer = !params.isRemote
+ ? await startCallbackServer({ timeoutMs: 5 * 60 * 1000 })
+ : null;
+
+ // 4. Open browser or show URL
+ if (callbackServer) {
+ await params.openUrl(authUrl);
+ const callback = await callbackServer.waitForCallback();
+ code = callback.searchParams.get("code");
+ } else {
+ await params.note(`Auth URL: ${authUrl}`, "OAuth");
+ const input = await params.prompt("Paste redirect URL:");
+ const parsed = parseCallbackInput(input);
+ code = parsed.code;
+ }
+
+ // 5. Exchange code for tokens
+ const tokens = await exchangeCode({ code, verifier });
+
+ // 6. Fetch additional user data
+ const email = await fetchUserEmail(tokens.access);
+
+ return { ...tokens, email };
+}
+```
+
+#### 4. Register Provider
+
+```typescript
+const yourProviderPlugin = {
+ id: "your-provider-auth",
+ name: "Your Provider Auth",
+ description: "OAuth for Your Provider",
+ configSchema: emptyPluginConfigSchema(),
+
+ register(api: OpenClawPluginApi) {
+ api.registerProvider({
+ id: "your-provider",
+ label: "Your Provider",
+ docsPath: "/providers/models",
+ aliases: ["yp"],
+
+ auth: [
+ {
+ id: "oauth",
+ label: "OAuth Login",
+ hint: "Browser-based authentication",
+ kind: "oauth",
+
+ run: async (ctx: ProviderAuthContext) => {
+ const spin = ctx.prompter.progress("Starting OAuth...");
+
+ try {
+ const result = await loginYourProvider({
+ isRemote: ctx.isRemote,
+ openUrl: ctx.openUrl,
+ prompt: async (msg) => String(await ctx.prompter.text({ message: msg })),
+ note: ctx.prompter.note,
+ log: (msg) => ctx.runtime.log(msg),
+ progress: spin,
+ });
+
+ return buildOauthProviderAuthResult({
+ providerId: "your-provider",
+ defaultModel: "your-provider/model-name",
+ access: result.access,
+ refresh: result.refresh,
+ expires: result.expires,
+ email: result.email,
+ notes: ["Provider-specific notes"],
+ });
+ } catch (err) {
+ spin.stop("OAuth failed");
+ throw err;
+ }
+ },
+ },
+ ],
+ });
+ },
+};
+
+export default yourProviderPlugin;
+```
+
+#### 5. Implement Usage Tracking (Optional)
+
+```typescript
+// src/infra/provider-usage.fetch.your-provider.ts
+export async function fetchYourProviderUsage(
+ token: string,
+ timeoutMs: number,
+ fetchFn: typeof fetch
+): Promise {
+ // Fetch usage data from provider API
+ const response = await fetchFn("https://api.provider.com/usage", {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+
+ const data = await response.json();
+
+ return {
+ provider: "your-provider",
+ displayName: "Your Provider",
+ windows: [
+ { label: "Credits", usedPercent: data.usedPercent },
+ ],
+ plan: data.planName,
+ };
+}
+```
+
+#### 6. Register Usage Fetcher
+
+```typescript
+// src/infra/provider-usage.load.ts
+case "your-provider":
+ return await fetchYourProviderUsage(auth.token, timeoutMs, fetchFn);
+```
+
+#### 7. Add Provider to Type Definitions
+
+```typescript
+// src/infra/provider-usage.types.ts
+export type SupportedProvider =
+ | "anthropic"
+ | "github-copilot"
+ | "google-gemini-cli"
+ | "google-antigravity"
+ | "your-provider" // Add here
+ | "minimax"
+ | "openai-codex";
+```
+
+#### 8. Add Auth Choice Handler
+
+```typescript
+// src/commands/auth-choice.apply.your-provider.ts
+import { applyAuthChoicePluginProvider } from "./auth-choice.apply.plugin-provider.js";
+
+export async function applyAuthChoiceYourProvider(
+ params: ApplyAuthChoiceParams
+): Promise {
+ return await applyAuthChoicePluginProvider(params, {
+ authChoice: "your-provider",
+ pluginId: "your-provider-auth",
+ providerId: "your-provider",
+ methodId: "oauth",
+ label: "Your Provider",
+ });
+}
+```
+
+#### 9. Export from Main Index
+
+```typescript
+// src/commands/auth-choice.apply.ts
+import { applyAuthChoiceYourProvider } from "./auth-choice.apply.your-provider.js";
+
+// In the switch statement:
+case "your-provider":
+ return await applyAuthChoiceYourProvider(params);
+```
+
+### Helper Utilities
+
+#### PKCE Generation
+```typescript
+function generatePkce(): { verifier: string; challenge: string } {
+ const verifier = randomBytes(32).toString("hex");
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
+ return { verifier, challenge };
+}
+```
+
+#### Callback Server
+```typescript
+async function startCallbackServer(params: { timeoutMs: number }) {
+ const port = 51121; // Your port
+
+ const server = createServer((request, response) => {
+ const url = new URL(request.url!, `http://localhost:${port}`);
+
+ if (url.pathname === "/oauth-callback") {
+ response.writeHead(200, { "Content-Type": "text/html" });
+ response.end("Authentication complete
");
+ resolveCallback(url);
+ server.close();
+ }
+ });
+
+ await new Promise((resolve, reject) => {
+ server.listen(port, "127.0.0.1", resolve);
+ server.once("error", reject);
+ });
+
+ return {
+ waitForCallback: () => callbackPromise,
+ close: () => new Promise((resolve) => server.close(resolve)),
+ };
+}
+```
+
+---
+
+## Testing Your Implementation
+
+### CLI Commands
+
+```bash
+# Enable the plugin
+openclaw plugins enable your-provider-auth
+
+# Restart gateway
+openclaw gateway restart
+
+# Authenticate
+openclaw models auth login --provider your-provider --set-default
+
+# List models
+openclaw models list
+
+# Set model
+openclaw models set your-provider/model-name
+
+# Check usage
+openclaw models usage
+```
+
+### Environment Variables for Testing
+
+```bash
+# Test specific providers only
+export OPENCLAW_LIVE_PROVIDERS="your-provider,google-antigravity"
+
+# Test with specific models
+export OPENCLAW_LIVE_GATEWAY_MODELS="your-provider/model-name"
+```
+
+---
+
+## References
+
+- **Source Files:**
+ - `extensions/google-antigravity-auth/index.ts` - Full OAuth implementation
+ - `src/infra/provider-usage.fetch.antigravity.ts` - Usage fetching
+ - `src/agents/pi-embedded-runner/google.ts` - Model sanitization
+ - `src/agents/model-forward-compat.ts` - Forward compatibility
+ - `src/plugin-sdk/provider-auth-result.ts` - Auth result builder
+ - `src/plugins/types.ts` - Plugin type definitions
+
+- **Documentation:**
+ - `docs/concepts/model-providers.md` - Provider overview
+ - `docs/concepts/usage-tracking.md` - Usage tracking
+
+---
+
+## Notes
+
+1. **Google Cloud Project:** Antigravity requires Gemini for Google Cloud to be enabled on your Google Cloud project
+2. **Quotas:** Uses Google Cloud project quotas (not separate billing)
+3. **Model Access:** Available models depend on your Google Cloud project configuration
+4. **Thinking Blocks:** Claude models via Antigravity require special handling of thinking blocks with signatures
+5. **Schema Sanitization:** Tool schemas must be sanitized to remove unsupported JSON Schema keywords
+
+---
+
+---
+
+## Common Error Handling
+
+### 1. Rate Limiting (HTTP 429)
+
+Antigravity returns a 429 error when project/model quotas are exhausted. The error response often contains a `quotaResetDelay` in the `details` field.
+
+**Example 429 Error:**
+```json
+{
+ "error": {
+ "code": 429,
+ "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.",
+ "status": "RESOURCE_EXHAUSTED",
+ "details": [
+ {
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+ "metadata": {
+ "quotaResetDelay": "4h30m28.060903746s"
+ }
+ }
+ ]
+ }
+}
+```
+
+### 2. Empty Responses (Restricted Models)
+
+Some models might show up in the available models list but return an empty response (200 OK but empty SSE stream). This usually happens for preview or restricted models that the current project doesn't have permission to use.
+
+**Treatment:** Treat empty responses as errors informing the user that the model might be restricted or invalid for their project.
+
+---
+
+## Troubleshooting
+
+### "Token expired"
+- Refresh OAuth tokens: `openclaw models auth login --provider google-antigravity`
+
+### "Gemini for Google Cloud is not enabled"
+- Enable the API in your Google Cloud Console
+
+### "Project not found"
+- Ensure your Google Cloud project has the necessary APIs enabled
+- Check that the project ID is correctly fetched during authentication
+
+### Models not appearing in list
+- Verify OAuth authentication completed successfully
+- Check auth profile storage: `~/.openclaw/agent/auth-profiles.json`
+- Ensure the plugin is enabled: `openclaw plugins list`
diff --git a/docs/ANTIGRAVITY_USAGE.md b/docs/ANTIGRAVITY_USAGE.md
new file mode 100644
index 000000000..8bf1fdfdb
--- /dev/null
+++ b/docs/ANTIGRAVITY_USAGE.md
@@ -0,0 +1,72 @@
+# Using Antigravity Provider in PicoClaw
+
+This guide explains how to set up and use the **Antigravity** (Google Cloud Code Assist) provider in PicoClaw.
+
+## Prerequisites
+
+1. A Google account.
+2. Google Cloud Code Assist enabled (usually available via the "Gemini for Google Cloud" onboarding).
+
+## 1. Authentication
+
+To authenticate with Antigravity, run the following command:
+
+```bash
+picoclaw auth login --provider antigravity
+```
+
+### Manual Authentication (Headless/VPS)
+If you are running on a server (Coolify/Docker) and cannot reach `localhost`, follow these steps:
+1. Run the command above.
+2. Copy the URL provided and open it in your local browser.
+3. Complete the login.
+4. Your browser will redirect to a `localhost:51121` URL (which will fail to load).
+5. **Copy that final URL** from your browser's address bar.
+6. **Paste it back into the terminal** where PicoClaw is waiting.
+
+PicoClaw will extract the authorization code and complete the process automatically.
+
+## 2. Managing Models
+
+### List Available Models
+To see which models your project has access to and check their quotas:
+
+```bash
+picoclaw auth models
+```
+
+### Switch Models
+You can change the default model in `~/.picoclaw/config.json` or override it via the CLI:
+
+```bash
+# Override for a single command
+picoclaw agent -m "Hello" --model claude-opus-4-6-thinking
+```
+
+## 3. Real-world Usage (Coolify/Docker)
+
+If you are deploying via Coolify or Docker, follow these steps to test:
+
+1. **Branch**: Use the `feat/antigravity-provider` branch.
+2. **Environment Variables**:
+ * `PICOCLAW_AGENTS_DEFAULTS_PROVIDER=antigravity`
+ * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-3-flash`
+3. **Authentication persistence**:
+ If you've logged in locally, you can copy your credentials to the server:
+ ```bash
+ scp ~/.picoclaw/auth-profiles.json user@your-server:~/.picoclaw/
+ ```
+ *Alternatively*, run the `auth login` command once on the server if you have terminal access.
+
+## 4. Troubleshooting
+
+* **Empty Response**: If a model returns an empty reply, it may be restricted for your project. Try `gemini-3-flash` or `claude-opus-4-6-thinking`.
+* **429 Rate Limit**: Antigravity has strict quotas. PicoClaw will display the "reset time" in the error message if you hit a limit.
+* **404 Not Found**: Ensure you are using a model ID from the `picoclaw auth models` list. Use the short ID (e.g., `gemini-3-flash`) not the full path.
+
+## 5. Summary of Working Models
+
+Based on testing, the following models are most reliable:
+* `gemini-3-flash` (Fast, highly available)
+* `gemini-2.5-flash-lite` (Lightweight)
+* `claude-opus-4-6-thinking` (Powerful, includes reasoning)
diff --git a/docs/design/provider-refactoring-tests.md b/docs/design/provider-refactoring-tests.md
new file mode 100644
index 000000000..fc6429278
--- /dev/null
+++ b/docs/design/provider-refactoring-tests.md
@@ -0,0 +1,179 @@
+# Provider Architecture Refactoring - Test Suite Summary
+
+> PRD: `tasks/prd-provider-refactoring.md`
+
+This document summarizes the complete test suite designed for the Provider architecture refactoring.
+
+## Test File Structure
+
+```
+pkg/
+├── config/
+│ ├── model_config_test.go # US-001, US-002: ModelConfig struct and GetModelConfig tests
+│ └── migration_test.go # US-003: Backward compatibility and migration tests
+├── providers/
+│ ├── registry_test.go # US-006: Load balancing tests
+│ ├── integration_test.go # E2E integration tests
+│ └── factory/
+│ └── factory_test.go # US-004, US-005: Provider factory tests
+```
+
+---
+
+## Test Case Checklist
+
+### 1. `pkg/config/model_config_test.go` - Configuration Parsing Tests
+
+| Test Name | Purpose | PRD Reference |
+|-----------|---------|---------------|
+| `TestModelConfig_Parsing` | Verify ModelConfig JSON parsing | US-001 |
+| `TestModelConfig_ModelListInConfig` | Verify model_list parsing in Config | US-001 |
+| `TestModelConfig_Validation` | Verify required field validation | US-001 |
+| `TestConfig_GetModelConfig_Found` | Verify GetModelConfig finds model | US-002 |
+| `TestConfig_GetModelConfig_NotFound` | Verify GetModelConfig returns error | US-002 |
+| `TestConfig_GetModelConfig_EmptyModelList` | Verify empty model_list handling | US-002 |
+| `TestConfig_BackwardCompatibility_ProvidersToModelList` | Verify old config conversion | US-003 |
+| `TestConfig_DeprecationWarning` | Verify deprecation warning | US-003 |
+| `TestModelConfig_ProtocolExtraction` | Verify protocol prefix extraction | US-004 |
+| `TestConfig_ModelNameUniqueness` | Verify model_name uniqueness | US-001 |
+
+### 2. `pkg/config/migration_test.go` - Migration Tests
+
+| Test Name | Purpose | PRD Reference |
+|-----------|---------|---------------|
+| `TestConvertProvidersToModelList_OpenAI` | OpenAI config conversion | US-003 |
+| `TestConvertProvidersToModelList_Anthropic` | Anthropic config conversion | US-003 |
+| `TestConvertProvidersToModelList_MultipleProviders` | Multiple provider conversion | US-003 |
+| `TestConvertProvidersToModelList_EmptyProviders` | Empty providers handling | US-003 |
+| `TestConvertProvidersToModelList_GitHubCopilot` | GitHub Copilot conversion | US-003 |
+| `TestConvertProvidersToModelList_Antigravity` | Antigravity conversion | US-003 |
+| `TestGenerateModelName_*` | Model name generation | US-003 |
+| `TestHasProvidersConfig_*` | Detect old config existence | US-003 |
+| `TestValidateMigration_*` | Migration validation | US-003 |
+| `TestMigrateConfig_DryRun` | Dry run migration | US-003 |
+| `TestMigrateConfig_Actual` | Actual migration | US-003 |
+
+### 3. `pkg/providers/registry_test.go` - Load Balancing Tests
+
+| Test Name | Purpose | PRD Reference |
+|-----------|---------|---------------|
+| `TestModelRegistry_SingleConfig` | Single config returns same result | US-006 |
+| `TestModelRegistry_RoundRobinSelection` | 3-config round-robin selection | US-006 |
+| `TestModelRegistry_RoundRobinTwoConfigs` | 2-config round-robin selection | US-006 |
+| `TestModelRegistry_ConcurrentAccess` | Concurrent access thread safety | US-006 |
+| `TestModelRegistry_RaceDetection` | Data race detection | US-006 |
+| `TestModelRegistry_ModelNotFound` | Model not found error | US-006 |
+| `TestModelRegistry_EmptyRegistry` | Empty registry handling | US-006 |
+| `TestModelRegistry_MultipleModels` | Multiple model registration | US-006 |
+| `TestModelRegistry_MixedSingleAndMultiple` | Single/multiple config mix | US-006 |
+| `TestModelRegistry_CaseSensitiveModelNames` | Case sensitivity | US-006 |
+
+### 4. `pkg/providers/factory/factory_test.go` - Provider Factory Tests
+
+| Test Name | Purpose | PRD Reference |
+|-----------|---------|---------------|
+| `TestCreateProviderFromConfig_OpenAI` | Create OpenAI provider | US-004 |
+| `TestCreateProviderFromConfig_OpenAIDefault` | Default openai protocol | US-004 |
+| `TestCreateProviderFromConfig_Anthropic` | Create Anthropic provider | US-004 |
+| `TestCreateProviderFromConfig_Antigravity` | Create Antigravity provider | US-004 |
+| `TestCreateProviderFromConfig_ClaudeCLI` | Create Claude CLI provider | US-004 |
+| `TestCreateProviderFromConfig_CodexCLI` | Create Codex CLI provider | US-004 |
+| `TestCreateProviderFromConfig_GitHubCopilot` | Create GitHub Copilot provider | US-004 |
+| `TestCreateProviderFromConfig_UnknownProtocol` | Unknown protocol error handling | US-004 |
+| `TestCreateProviderFromConfig_MissingAPIKey` | Missing API key error | US-004 |
+| `TestExtractProtocol` | Protocol prefix extraction | US-004 |
+| `TestCreateProvider_UsesModelList` | Create using model_list | US-005 |
+| `TestCreateProvider_FallbackToProviders` | Fallback to providers | US-005 |
+| `TestCreateProvider_PriorityModelListOverProviders` | model_list priority | US-005 |
+
+### 5. `pkg/providers/integration_test.go` - E2E Integration Tests
+
+| Test Name | Purpose | PRD Reference |
+|-----------|---------|---------------|
+| `TestE2E_OpenAICompatibleProvider_NoCodeChange` | Zero-code provider addition | Goal |
+| `TestE2E_LoadBalancing_RoundRobin` | Load balancing actual effect | US-006 |
+| `TestE2E_BackwardCompatibility_OldProvidersConfig` | Old config compatibility | US-003 |
+| `TestE2E_ErrorHandling_ModelNotFound` | Model not found | FR-30 |
+| `TestE2E_ErrorHandling_MissingAPIKey` | Missing API key | FR-31 |
+| `TestE2E_ErrorHandling_InvalidAPIBase` | Invalid API base | FR-30 |
+| `TestE2E_ToolCalls_OpenAICompatible` | Tool call support | - |
+| `TestE2E_AntigravityProvider` | Antigravity provider | US-004 |
+| `TestE2E_ClaudeCLIProvider` | Claude CLI provider | US-004 |
+
+### 6. Performance Tests
+
+| Test Name | Purpose |
+|-----------|---------|
+| `BenchmarkCreateProviderFromConfig` | Provider creation performance |
+| `BenchmarkGetModelConfig` | Model lookup performance |
+| `BenchmarkGetModelConfigParallel` | Concurrent lookup performance |
+
+---
+
+## Running Tests
+
+```bash
+# Run all tests
+go test ./pkg/... -v
+
+# Run with data race detection
+go test ./pkg/... -race
+
+# Run specific package tests
+go test ./pkg/config -v
+go test ./pkg/providers -v
+go test ./pkg/providers/factory -v
+
+# Run E2E tests
+go test ./pkg/providers -run TestE2E -v
+
+# Run performance tests
+go test ./pkg/providers -bench=. -benchmem
+```
+
+---
+
+## PRD Acceptance Criteria Mapping
+
+| PRD Acceptance Criteria | Test Cases |
+|------------------------|------------|
+| US-001: Add ModelConfig struct | `TestModelConfig_Parsing`, `TestModelConfig_Validation` |
+| US-001: model_name unique | `TestConfig_ModelNameUniqueness` |
+| US-002: GetModelConfig method | `TestConfig_GetModelConfig_*` |
+| US-003: Auto-convert providers | `TestConvertProvidersToModelList_*` |
+| US-003: Deprecation warning | `TestConfig_DeprecationWarning` |
+| US-003: Existing tests pass | (existing test files unchanged) |
+| US-004: Protocol prefix factory | `TestExtractProtocol`, `TestCreateProviderFromConfig_*` |
+| US-004: Default prefix openai | `TestCreateProviderFromConfig_OpenAIDefault` |
+| US-005: CreateProvider uses factory | `TestCreateProvider_*` |
+| US-006: Round-robin selection | `TestModelRegistry_RoundRobin*` |
+| US-006: Thread-safe atomic | `TestModelRegistry_RaceDetection` |
+
+---
+
+## Recommended Implementation Order
+
+1. **Phase 1: Configuration Structure** (US-001, US-002)
+ - Implement `ModelConfig` struct
+ - Implement `GetModelConfig` method
+ - Run `model_config_test.go`
+
+2. **Phase 2: Protocol Factory** (US-004)
+ - Implement `CreateProviderFromConfig`
+ - Implement `ExtractProtocol`
+ - Run `factory_test.go`
+
+3. **Phase 3: Load Balancing** (US-006)
+ - Implement `ModelRegistry`
+ - Implement round-robin selection
+ - Run `registry_test.go` (with `-race`)
+
+4. **Phase 4: Backward Compatibility** (US-003, US-005)
+ - Implement `ConvertProvidersToModelList`
+ - Refactor `CreateProvider`
+ - Run `migration_test.go`
+ - Verify existing tests pass
+
+5. **Phase 5: E2E Verification**
+ - Run `integration_test.go`
+ - Manual testing with `config.example.json`
diff --git a/docs/design/provider-refactoring.md b/docs/design/provider-refactoring.md
new file mode 100644
index 000000000..a214d9857
--- /dev/null
+++ b/docs/design/provider-refactoring.md
@@ -0,0 +1,334 @@
+# Provider Architecture Refactoring Design
+
+> Issue: #283
+> Discussion: #122
+> Branch: feat/refactor-provider-by-protocol
+
+## 1. Current Problems
+
+### 1.1 Configuration Structure Issues
+
+**Current State**: Each Provider requires a predefined field in `ProvidersConfig`
+
+```go
+type ProvidersConfig struct {
+ Anthropic ProviderConfig `json:"anthropic"`
+ OpenAI ProviderConfig `json:"openai"`
+ DeepSeek ProviderConfig `json:"deepseek"`
+ Qwen ProviderConfig `json:"qwen"`
+ Cerebras ProviderConfig `json:"cerebras"`
+ VolcEngine ProviderConfig `json:"volcengine"`
+ // ... every new provider requires changes here
+}
+```
+
+**Problems**:
+- Adding a new Provider requires modifying Go code (struct definition)
+- `CreateProvider` function in `http_provider.go` has 200+ lines of switch-case
+- Most Providers are OpenAI-compatible, but code is duplicated
+
+### 1.2 Code Bloat Trend
+
+Recent PRs demonstrate this issue:
+
+| PR | Provider | Code Changes |
+|----|----------|--------------|
+| #365 | Qwen | +17 lines to http_provider.go |
+| #333 | Cerebras | +17 lines to http_provider.go |
+| #368 | Volcengine | +18 lines to http_provider.go |
+
+Each OpenAI-compatible Provider requires:
+1. Modify `config.go` to add configuration field
+2. Modify `http_provider.go` to add switch case
+3. Update documentation
+
+### 1.3 Agent-Provider Coupling
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "provider": "deepseek", // need to know provider name
+ "model": "deepseek-chat"
+ }
+ }
+}
+```
+
+Problem: Agent needs to know both `provider` and `model`, adding complexity.
+
+---
+
+## 2. New Approach: model_list
+
+### 2.1 Core Principles
+
+Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
+
+1. **Model-centric**: Users care about models, not providers
+2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.2`, `anthropic/claude-sonnet-4.6`
+3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
+
+### 2.2 New Configuration Structure
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "deepseek-chat",
+ "model": "openai/deepseek-chat",
+ "api_base": "https://api.deepseek.com/v1",
+ "api_key": "sk-xxx"
+ },
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-xxx"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-xxx"
+ },
+ {
+ "model_name": "gemini-3-flash",
+ "model": "antigravity/gemini-3-flash",
+ "auth_method": "oauth"
+ },
+ {
+ "model_name": "my-company-llm",
+ "model": "openai/company-model-v1",
+ "api_base": "https://llm.company.com/v1",
+ "api_key": "xxx"
+ }
+ ],
+
+ "agents": {
+ "defaults": {
+ "model": "deepseek-chat",
+ "max_tokens": 8192,
+ "temperature": 0.7
+ }
+ }
+}
+```
+
+### 2.3 Go Struct Definition
+
+```go
+type Config struct {
+ ModelList []ModelConfig `json:"model_list"` // new
+ Providers ProvidersConfig `json:"providers"` // old, deprecated
+
+ Agents AgentsConfig `json:"agents"`
+ Channels ChannelsConfig `json:"channels"`
+ // ...
+}
+
+type ModelConfig struct {
+ // Required
+ ModelName string `json:"model_name"` // user-facing name (alias)
+ Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.2
+
+ // Common config
+ APIBase string `json:"api_base,omitempty"`
+ APIKey string `json:"api_key,omitempty"`
+ Proxy string `json:"proxy,omitempty"`
+
+ // Special provider config
+ AuthMethod string `json:"auth_method,omitempty"` // oauth, token
+ ConnectMode string `json:"connect_mode,omitempty"` // stdio, grpc
+
+ // Optional optimizations
+ RPM int `json:"rpm,omitempty"` // rate limit
+ MaxTokensField string `json:"max_tokens_field,omitempty"` // max_tokens or max_completion_tokens
+}
+```
+
+### 2.4 Protocol Recognition
+
+Identify protocol via prefix in `model` field:
+
+| Prefix | Protocol | Description |
+|--------|----------|-------------|
+| `openai/` | OpenAI-compatible | Most common, includes DeepSeek, Qwen, Groq, etc. |
+| `anthropic/` | Anthropic | Claude series specific |
+| `antigravity/` | Antigravity | Google Cloud Code Assist |
+| `gemini/` | Gemini | Google Gemini native API (if needed) |
+
+---
+
+## 3. Design Rationale
+
+### 3.1 Problems Solved
+
+| Problem | Old Approach | New Approach |
+|---------|--------------|--------------|
+| Add OpenAI-compatible Provider | Change 3 code locations | Add one config entry |
+| Agent specifies model | Need provider + model | Only need model |
+| Code duplication | Each Provider duplicates logic | Share protocol implementation |
+| Multi-Agent support | Complex | Naturally compatible |
+
+### 3.2 Multi-Agent Compatibility
+
+```json
+{
+ "model_list": [...],
+
+ "agents": {
+ "defaults": {
+ "model": "deepseek-chat"
+ },
+ "coder": {
+ "model": "gpt-5.2",
+ "system_prompt": "You are a coding assistant..."
+ },
+ "translator": {
+ "model": "claude-sonnet-4.6"
+ }
+ }
+}
+```
+
+Each Agent only needs to specify `model` (corresponds to `model_name` in `model_list`).
+
+### 3.3 Industry Comparison
+
+**LiteLLM** (most mature open-source LLM Proxy) uses similar design:
+
+```yaml
+model_list:
+ - model_name: gpt-4o
+ litellm_params:
+ model: openai/gpt-5.2
+ api_key: xxx
+ - model_name: my-custom
+ litellm_params:
+ model: openai/custom-model
+ api_base: https://my-api.com/v1
+```
+
+---
+
+## 4. Migration Plan
+
+### 4.1 Phase 1: Compatibility Period (v1.x)
+
+Support both `providers` and `model_list`:
+
+```go
+func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
+ // Prefer new config
+ if len(c.ModelList) > 0 {
+ return c.findModelByName(modelName)
+ }
+
+ // Backward compatibility with old config
+ if !c.Providers.IsEmpty() {
+ logger.Warn("'providers' config is deprecated, please migrate to 'model_list'")
+ return c.convertFromProviders(modelName)
+ }
+
+ return nil, fmt.Errorf("model %s not found", modelName)
+}
+```
+
+### 4.2 Phase 2: Warning Period (late v1.x)
+
+- Print more prominent warnings at startup
+- Provide automatic migration script
+- Mark `providers` as deprecated in documentation
+
+### 4.3 Phase 3: Removal Period (v2.0)
+
+- Completely remove `providers` support
+- Remove `agents.defaults.provider` field
+- Only support `model_list`
+
+### 4.4 Configuration Migration Example
+
+**Old Config**:
+```json
+{
+ "providers": {
+ "deepseek": {
+ "api_key": "sk-xxx",
+ "api_base": "https://api.deepseek.com/v1"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "deepseek",
+ "model": "deepseek-chat"
+ }
+ }
+}
+```
+
+**New Config**:
+```json
+{
+ "model_list": [
+ {
+ "model_name": "deepseek-chat",
+ "model": "openai/deepseek-chat",
+ "api_base": "https://api.deepseek.com/v1",
+ "api_key": "sk-xxx"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "deepseek-chat"
+ }
+ }
+}
+```
+
+---
+
+## 5. Implementation Checklist
+
+### 5.1 Configuration Layer
+
+- [ ] Add `ModelConfig` struct
+- [ ] Add `Config.ModelList` field
+- [ ] Implement `GetModelConfig(modelName)` method
+- [ ] Implement old config compatibility conversion
+- [ ] Add `model_name` uniqueness validation
+
+### 5.2 Provider Layer
+
+- [ ] Create `pkg/providers/factory/` directory
+- [ ] Implement `CreateProviderFromModelConfig()`
+- [ ] Refactor `http_provider.go` to `openai/provider.go`
+- [ ] Maintain backward compatibility for old `CreateProvider()`
+
+### 5.3 Testing
+
+- [ ] New config unit tests
+- [ ] Old config compatibility tests
+- [ ] Integration tests
+
+### 5.4 Documentation
+
+- [ ] Update README
+- [ ] Update config.example.json
+- [ ] Write migration guide
+
+---
+
+## 6. Risks and Mitigations
+
+| Risk | Mitigation |
+|------|------------|
+| Breaking existing configs | Compatibility period keeps old config working |
+| User migration cost | Provide automatic migration script |
+| Special Provider incompatibility | Keep `auth_method` and other extension fields |
+
+---
+
+## 7. References
+
+- [LiteLLM Config Documentation](https://docs.litellm.ai/docs/proxy/configs)
+- [One-API GitHub](https://github.com/songquanpeng/one-api)
+- Discussion #122: Refactor Provider Architecture
diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md
new file mode 100644
index 000000000..0682bae1a
--- /dev/null
+++ b/docs/migration/model-list-migration.md
@@ -0,0 +1,211 @@
+# Migration Guide: From `providers` to `model_list`
+
+This guide explains how to migrate from the legacy `providers` configuration to the new `model_list` format.
+
+## Why Migrate?
+
+The new `model_list` configuration offers several advantages:
+
+- **Zero-code provider addition**: Add OpenAI-compatible providers with configuration only
+- **Load balancing**: Configure multiple endpoints for the same model
+- **Protocol-based routing**: Use prefixes like `openai/`, `anthropic/`, etc.
+- **Cleaner configuration**: Model-centric instead of vendor-centric
+
+## Timeline
+
+| Version | Status |
+|---------|--------|
+| v1.x | `model_list` introduced, `providers` deprecated but functional |
+| v1.x+1 | Prominent deprecation warnings, migration tool available |
+| v2.0 | `providers` configuration removed |
+
+## Before and After
+
+### Before: Legacy `providers` Configuration
+
+```json
+{
+ "providers": {
+ "openai": {
+ "api_key": "sk-your-openai-key",
+ "api_base": "https://api.openai.com/v1"
+ },
+ "anthropic": {
+ "api_key": "sk-ant-your-key"
+ },
+ "deepseek": {
+ "api_key": "sk-your-deepseek-key"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "openai",
+ "model": "gpt-5.2"
+ }
+ }
+}
+```
+
+### After: New `model_list` Configuration
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key",
+ "api_base": "https://api.openai.com/v1"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+ },
+ {
+ "model_name": "deepseek",
+ "model": "deepseek/deepseek-chat",
+ "api_key": "sk-your-deepseek-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gpt4"
+ }
+ }
+}
+```
+
+## Protocol Prefixes
+
+The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
+
+| Prefix | Description | Example |
+|--------|-------------|---------|
+| `openai/` | OpenAI API (default) | `openai/gpt-5.2` |
+| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
+| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
+| `claude-cli/` | Claude CLI (local) | `claude-cli/claude-sonnet-4.6` |
+| `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` |
+| `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` |
+| `openrouter/` | OpenRouter | `openrouter/anthropic/claude-sonnet-4.6` |
+| `groq/` | Groq API | `groq/llama-3.1-70b` |
+| `deepseek/` | DeepSeek API | `deepseek/deepseek-chat` |
+| `cerebras/` | Cerebras API | `cerebras/llama-3.3-70b` |
+| `qwen/` | Alibaba Qwen | `qwen/qwen-max` |
+
+**Note**: If no prefix is specified, `openai/` is used as the default.
+
+## ModelConfig Fields
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `model_name` | Yes | User-facing alias for the model |
+| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.2`) |
+| `api_base` | No | API endpoint URL |
+| `api_key` | No* | API authentication key |
+| `proxy` | No | HTTP proxy URL |
+| `auth_method` | No | Authentication method: `oauth`, `token` |
+| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
+| `rpm` | No | Requests per minute limit |
+| `max_tokens_field` | No | Field name for max tokens |
+
+*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
+
+## Load Balancing
+
+Configure multiple endpoints for the same model to distribute load:
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-key1",
+ "api_base": "https://api1.example.com/v1"
+ },
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-key2",
+ "api_base": "https://api2.example.com/v1"
+ },
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-key3",
+ "api_base": "https://api3.example.com/v1"
+ }
+ ]
+}
+```
+
+When you request model `gpt4`, requests will be distributed across all three endpoints using round-robin selection.
+
+## Adding a New OpenAI-Compatible Provider
+
+With `model_list`, adding a new provider requires zero code changes:
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "my-custom-llm",
+ "model": "openai/my-model-v1",
+ "api_key": "your-api-key",
+ "api_base": "https://api.your-provider.com/v1"
+ }
+ ]
+}
+```
+
+Just specify `openai/` as the protocol (or omit it for the default), and provide your provider's API base URL.
+
+## Backward Compatibility
+
+During the migration period, your existing `providers` configuration will continue to work:
+
+1. If `model_list` is empty and `providers` has data, the system auto-converts internally
+2. A deprecation warning is logged: `"providers config is deprecated, please migrate to model_list"`
+3. All existing functionality remains unchanged
+
+## Migration Checklist
+
+- [ ] Identify all providers you're currently using
+- [ ] Create `model_list` entries for each provider
+- [ ] Use appropriate protocol prefixes
+- [ ] Update `agents.defaults.model` to reference the new `model_name`
+- [ ] Test that all models work correctly
+- [ ] Remove or comment out the old `providers` section
+
+## Troubleshooting
+
+### Model not found error
+
+```
+model "xxx" not found in model_list or providers
+```
+
+**Solution**: Ensure the `model_name` in `model_list` matches the value in `agents.defaults.model`.
+
+### Unknown protocol error
+
+```
+unknown protocol "xxx" in model "xxx/model-name"
+```
+
+**Solution**: Use a supported protocol prefix. See the [Protocol Prefixes](#protocol-prefixes) table above.
+
+### Missing API key error
+
+```
+api_key or api_base is required for HTTP-based protocol "xxx"
+```
+
+**Solution**: Provide `api_key` and/or `api_base` for HTTP-based providers.
+
+## Need Help?
+
+- [GitHub Issues](https://github.com/sipeed/picoclaw/issues)
+- [Discussion #122](https://github.com/sipeed/picoclaw/discussions/122): Original proposal
diff --git a/docs/picoclaw_community_roadmap_260216.md b/docs/picoclaw_community_roadmap_260216.md
new file mode 100644
index 000000000..cfcc30f17
--- /dev/null
+++ b/docs/picoclaw_community_roadmap_260216.md
@@ -0,0 +1,112 @@
+## 🚀 Join the PicoClaw Journey: Call for Community Volunteers & Roadmap Reveal
+
+**Hello, PicoClaw Community!**
+
+First, a massive thank you to everyone for your enthusiasm and PR contributions. It is because of you that PicoClaw continues to iterate and evolve so rapidly. Thanks to the simplicity and accessibility of the **Go language**, we’ve seen a non-stop stream of high-quality PRs!
+
+PicoClaw is growing much faster than we anticipated. As we are currently in the midst of the **Chinese New Year holiday**, we are looking to recruit community volunteers to help us maintain this incredible momentum.
+
+This document outlines the specific volunteer roles we need right now and provides a look at our upcoming **Roadmap**.
+
+### 🎁 Community Perks
+
+To show our appreciation, developers who officially join our community operations will receive:
+
+* **Exclusive AI Hardware:** Our upcoming, unreleased AI device.
+* **Token Discounts:** Potential discounts on LLM tokens (currently in negotiations with major providers).
+
+### 🎥 Calling All Content Creators!
+
+Not a developer? You can still help! We welcome users to post **PicoClaw reviews or tutorials**.
+
+* **Twitter:** Use the tag **#picoclaw** and mention **@SipeedIO**.
+* **Bilibili:** Mention **@Sipeed矽速科技** or send us a DM.
+We will be rewarding high-quality content creators with the same perks as our community developers!
+
+---
+
+## 🛠️ Urgent Volunteer Roles
+
+We are looking for experts in the following areas:
+
+1. **Issue/PR Reviewers**
+* **The Mission:** With PRs and Issues exploding in volume, we need help with initial triage, evaluation, and merging.
+* **Focus:** Preliminary merging and community health. Efficiency optimization and security audits will be handled by specialized roles.
+
+
+2. **Resource Optimization Experts**
+* **The Mission:** Rapid growth has introduced dependencies that are making PicoClaw a bit "heavy." We want to keep it lean.
+* **Focus:** Analyzing resource growth between releases and trimming redundancy.
+* **Priority:** **RAM usage optimization** > Binary size reduction.
+
+
+3. **Security Audit & Bug Fixes**
+* **The Mission:** Due to the "vibe coding" nature of our early stages, we need a thorough review of network security and AI permission management.
+* **Focus:** Auditing the codebase for vulnerabilities and implementing robust fixes.
+
+
+4. **Documentation & DX (Developer Experience)**
+* **The Mission:** Our current README is a bit outdated. We need "step-by-step" guides that even beginners can follow.
+* **Focus:** Creating clear, user-friendly documentation for both setup and development.
+
+
+5. **AI-Powered CI/CD Optimization**
+* **The Mission:** PicoClaw started as a "vibe coding" experiment; now we want to use AI to manage it.
+* **Focus:** Automating builds with AI and exploring AI-driven issue resolution.
+
+**How to Apply:** > If you are interested in any of the roles above, please send an email to support@sipeed.com with the subject line: [Apply: PicoClaw Expert Volunteer] + Your Desired Role.
+Please include a brief introduction and any relevant experience or portfolio links. We will review all applications and grant project permissions to selected contributors!
+
+---
+
+## 📍 The Roadmap
+
+Interested in a specific feature? You can "claim" these tasks and start building:
+
+###
+* **Provider:**
+ * **Provider Refactor:** Currently being handled by **@Daming** (ETA: 5 days)
+ * You can still submit code; Daming will merge it into the new implementation.
+* **Channels:**
+ * Support for OneBot, additional platforms
+ * attachments (images, audio, video, files).
+* **Skills:**
+ * Implementing `find_skill` to discover tools via [openclaw/skills](https://github.com/openclaw/skills) and other platforms.
+* **Operations:** * MCP Support.
+ * Android operations (e.g., botdrop).
+ * Browser automation via CDP or ActionBook.
+
+
+* **Multi-Agent Ecosystem:**
+ * **Basic Model-Agnet** S
+ * **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens).
+ * **Swarm Mode.**
+ * **AIEOS Integration.**
+
+
+* **Branding:**
+ * **Logo**: We need a cute logo! We’re leaning toward a **Mantis Shrimp**—small, but packs a legendary punch!
+
+
+We have officially created these tasks as GitHub Issues, all marked with the roadmap tag.
+This list will be updated continuously as we progress.
+If you would like to claim a task, please feel free to start a conversation by commenting directly on the corresponding issue!
+
+---
+
+## 🤝 How to Join
+
+**Everything is open to your creativity!** If you have a wild idea, just PR it.
+
+1. **The Fast Track:** Once you have at least **one merged PR**, you are eligible to join our **Developer Discord** to help plan the future of PicoClaw.
+2. **The Application Track:** If you haven’t submitted a PR yet but want to dive in, email **support@sipeed.com** with the subject:
+> `[Apply Join PicoClaw Dev Group] + Your GitHub Account`
+> Include the role you're interested in and any evidence of your development experience.
+
+
+
+### Looking Ahead
+
+Powered by PicoClaw, we are crafting a Swarm AI Assistant to transform your environment into a seamless network of personal stewards. By automating the friction of daily life, we empower you to transcend the ordinary and freely explore your creative potential.
+
+**Finally, Happy Chinese New Year to everyone!** May PicoClaw gallop forward in this **Year of the Horse!** 🐎
diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md
new file mode 100644
index 000000000..8777ddbd6
--- /dev/null
+++ b/docs/tools_configuration.md
@@ -0,0 +1,122 @@
+# Tools Configuration
+
+PicoClaw's tools configuration is located in the `tools` field of `config.json`.
+
+## Directory Structure
+
+```json
+{
+ "tools": {
+ "web": { ... },
+ "exec": { ... },
+ "approval": { ... },
+ "cron": { ... }
+ }
+}
+```
+
+## Web Tools
+
+Web tools are used for web search and fetching.
+
+### Brave
+
+| Config | Type | Default | Description |
+|--------|------|---------|-------------|
+| `enabled` | bool | false | Enable Brave search |
+| `api_key` | string | - | Brave Search API key |
+| `max_results` | int | 5 | Maximum number of results |
+
+### DuckDuckGo
+
+| Config | Type | Default | Description |
+|--------|------|---------|-------------|
+| `enabled` | bool | true | Enable DuckDuckGo search |
+| `max_results` | int | 5 | Maximum number of results |
+
+### Perplexity
+
+| Config | Type | Default | Description |
+|--------|------|---------|-------------|
+| `enabled` | bool | false | Enable Perplexity search |
+| `api_key` | string | - | Perplexity API key |
+| `max_results` | int | 5 | Maximum number of results |
+
+## Exec Tool
+
+The exec tool is used to execute shell commands.
+
+| Config | Type | Default | Description |
+|--------|------|---------|-------------|
+| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
+| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
+
+### Functionality
+
+- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns
+- **`custom_deny_patterns`**: Add custom deny regex patterns; commands matching these will be blocked
+
+### Default Blocked Command Patterns
+
+By default, PicoClaw blocks the following dangerous commands:
+
+- Delete commands: `rm -rf`, `del /f/q`, `rmdir /s`
+- Disk operations: `format`, `mkfs`, `diskpart`, `dd if=`, writing to `/dev/sd*`
+- System operations: `shutdown`, `reboot`, `poweroff`
+- Command substitution: `$()`, `${}`, backticks
+- Pipe to shell: `| sh`, `| bash`
+- Privilege escalation: `sudo`, `chmod`, `chown`
+- Process control: `pkill`, `killall`, `kill -9`
+- Remote operations: `curl | sh`, `wget | sh`, `ssh`
+- Package management: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user`
+- Containers: `docker run`, `docker exec`
+- Git: `git push`, `git force`
+- Other: `eval`, `source *.sh`
+
+### Configuration Example
+
+```json
+{
+ "tools": {
+ "exec": {
+ "enable_deny_patterns": true,
+ "custom_deny_patterns": [
+ "\\brm\\s+-r\\b",
+ "\\bkillall\\s+python"
+ ],
+ }
+ }
+}
+```
+
+## Approval Tool
+
+The approval tool controls permissions for dangerous operations.
+
+| Config | Type | Default | Description |
+|--------|------|---------|-------------|
+| `enabled` | bool | true | Enable approval functionality |
+| `write_file` | bool | true | Require approval for file writes |
+| `edit_file` | bool | true | Require approval for file edits |
+| `append_file` | bool | true | Require approval for file appends |
+| `exec` | bool | true | Require approval for command execution |
+| `timeout_minutes` | int | 5 | Approval timeout in minutes |
+
+## Cron Tool
+
+The cron tool is used for scheduling periodic tasks.
+
+| Config | Type | Default | Description |
+|--------|------|---------|-------------|
+| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
+
+## Environment Variables
+
+All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS__`:
+
+For example:
+- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
+- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
+- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
+
+Note: Array-type environment variables are not currently supported and must be set via the config file.
diff --git a/go.mod b/go.mod
index 98aecd6ab..1f88639c8 100644
--- a/go.mod
+++ b/go.mod
@@ -15,11 +15,16 @@ require (
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
github.com/slack-go/slack v0.17.3
+ github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1
golang.org/x/oauth2 v0.35.0
)
-
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
@@ -28,9 +33,9 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/github/copilot-sdk/go v0.1.23
- github.com/google/jsonschema-go v0.4.2 // indirect
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/google/jsonschema-go v0.4.2 // indirect
github.com/grbit/go-json v0.11.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@@ -47,5 +52,4 @@ require (
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
-
)
diff --git a/go.sum b/go.sum
index 6a565b93e..0e95bf5cd 100644
--- a/go.sum
+++ b/go.sum
@@ -58,6 +58,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -78,9 +80,11 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
@@ -102,6 +106,7 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
@@ -242,6 +247,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
diff --git a/pkg/agent/context.go b/pkg/agent/context.go
index cf5ce2913..27e3ef9dc 100644
--- a/pkg/agent/context.go
+++ b/pkg/agent/context.go
@@ -189,16 +189,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
}
- //This fix prevents the session memory from LLM failure due to elimination of toolu_IDs required from LLM
- // --- INICIO DEL FIX ---
- //Diegox-17
- for len(history) > 0 && (history[0].Role == "tool") {
- logger.DebugCF("agent", "Removing orphaned tool message from history to prevent LLM error",
- map[string]interface{}{"role": history[0].Role})
- history = history[1:]
- }
- //Diegox-17
- // --- FIN DEL FIX ---
+ history = sanitizeHistoryForProvider(history)
messages = append(messages, providers.Message{
Role: "system",
@@ -207,14 +198,58 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
messages = append(messages, history...)
- messages = append(messages, providers.Message{
- Role: "user",
- Content: currentMessage,
- })
+ if strings.TrimSpace(currentMessage) != "" {
+ messages = append(messages, providers.Message{
+ Role: "user",
+ Content: currentMessage,
+ })
+ }
return messages
}
+func sanitizeHistoryForProvider(history []providers.Message) []providers.Message {
+ if len(history) == 0 {
+ return history
+ }
+
+ sanitized := make([]providers.Message, 0, len(history))
+ for _, msg := range history {
+ switch msg.Role {
+ case "tool":
+ if len(sanitized) == 0 {
+ logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]interface{}{})
+ continue
+ }
+ last := sanitized[len(sanitized)-1]
+ if last.Role != "assistant" || len(last.ToolCalls) == 0 {
+ logger.DebugCF("agent", "Dropping orphaned tool message", map[string]interface{}{})
+ continue
+ }
+ sanitized = append(sanitized, msg)
+
+ case "assistant":
+ if len(msg.ToolCalls) > 0 {
+ if len(sanitized) == 0 {
+ logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]interface{}{})
+ continue
+ }
+ prev := sanitized[len(sanitized)-1]
+ if prev.Role != "user" && prev.Role != "tool" {
+ logger.DebugCF("agent", "Dropping assistant tool-call turn with invalid predecessor", map[string]interface{}{"prev_role": prev.Role})
+ continue
+ }
+ }
+ sanitized = append(sanitized, msg)
+
+ default:
+ sanitized = append(sanitized, msg)
+ }
+ }
+
+ return sanitized
+}
+
func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message {
messages = append(messages, providers.Message{
Role: "tool",
diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go
new file mode 100644
index 000000000..37b253685
--- /dev/null
+++ b/pkg/agent/instance.go
@@ -0,0 +1,159 @@
+package agent
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/routing"
+ "github.com/sipeed/picoclaw/pkg/session"
+ "github.com/sipeed/picoclaw/pkg/tools"
+)
+
+// AgentInstance represents a fully configured agent with its own workspace,
+// session manager, context builder, and tool registry.
+type AgentInstance struct {
+ ID string
+ Name string
+ Model string
+ Fallbacks []string
+ Workspace string
+ MaxIterations int
+ MaxTokens int
+ Temperature float64
+ ContextWindow int
+ Provider providers.LLMProvider
+ Sessions *session.SessionManager
+ ContextBuilder *ContextBuilder
+ Tools *tools.ToolRegistry
+ Subagents *config.SubagentsConfig
+ SkillsFilter []string
+ Candidates []providers.FallbackCandidate
+}
+
+// NewAgentInstance creates an agent instance from config.
+func NewAgentInstance(
+ agentCfg *config.AgentConfig,
+ defaults *config.AgentDefaults,
+ cfg *config.Config,
+ provider providers.LLMProvider,
+) *AgentInstance {
+ workspace := resolveAgentWorkspace(agentCfg, defaults)
+ os.MkdirAll(workspace, 0755)
+
+ model := resolveAgentModel(agentCfg, defaults)
+ fallbacks := resolveAgentFallbacks(agentCfg, defaults)
+
+ restrict := defaults.RestrictToWorkspace
+ toolsRegistry := tools.NewToolRegistry()
+ toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
+ toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
+ toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
+ toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg))
+ toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
+ toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
+
+ sessionsDir := filepath.Join(workspace, "sessions")
+ sessionsManager := session.NewSessionManager(sessionsDir)
+
+ contextBuilder := NewContextBuilder(workspace)
+ contextBuilder.SetToolsRegistry(toolsRegistry)
+
+ agentID := routing.DefaultAgentID
+ agentName := ""
+ var subagents *config.SubagentsConfig
+ var skillsFilter []string
+
+ if agentCfg != nil {
+ agentID = routing.NormalizeAgentID(agentCfg.ID)
+ agentName = agentCfg.Name
+ subagents = agentCfg.Subagents
+ skillsFilter = agentCfg.Skills
+ }
+
+ maxIter := defaults.MaxToolIterations
+ if maxIter == 0 {
+ maxIter = 20
+ }
+
+ maxTokens := defaults.MaxTokens
+ if maxTokens == 0 {
+ maxTokens = 8192
+ }
+
+ temperature := 0.7
+ if defaults.Temperature != nil {
+ temperature = *defaults.Temperature
+ }
+
+ // Resolve fallback candidates
+ modelCfg := providers.ModelConfig{
+ Primary: model,
+ Fallbacks: fallbacks,
+ }
+ candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
+
+ return &AgentInstance{
+ ID: agentID,
+ Name: agentName,
+ Model: model,
+ Fallbacks: fallbacks,
+ Workspace: workspace,
+ MaxIterations: maxIter,
+ MaxTokens: maxTokens,
+ Temperature: temperature,
+ ContextWindow: maxTokens,
+ Provider: provider,
+ Sessions: sessionsManager,
+ ContextBuilder: contextBuilder,
+ Tools: toolsRegistry,
+ Subagents: subagents,
+ SkillsFilter: skillsFilter,
+ Candidates: candidates,
+ }
+}
+
+// resolveAgentWorkspace determines the workspace directory for an agent.
+func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
+ if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
+ return expandHome(strings.TrimSpace(agentCfg.Workspace))
+ }
+ if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
+ return expandHome(defaults.Workspace)
+ }
+ home, _ := os.UserHomeDir()
+ id := routing.NormalizeAgentID(agentCfg.ID)
+ return filepath.Join(home, ".picoclaw", "workspace-"+id)
+}
+
+// resolveAgentModel resolves the primary model for an agent.
+func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
+ if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
+ return strings.TrimSpace(agentCfg.Model.Primary)
+ }
+ return defaults.Model
+}
+
+// resolveAgentFallbacks resolves the fallback models for an agent.
+func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
+ if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil {
+ return agentCfg.Model.Fallbacks
+ }
+ return defaults.ModelFallbacks
+}
+
+func expandHome(path string) string {
+ if path == "" {
+ return path
+ }
+ if path[0] == '~' {
+ home, _ := os.UserHomeDir()
+ if len(path) > 1 && path[1] == '/' {
+ return home + path[1:]
+ }
+ return home
+ }
+ return path
+}
diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go
new file mode 100644
index 000000000..fcc8e9bea
--- /dev/null
+++ b/pkg/agent/instance_test.go
@@ -0,0 +1,95 @@
+package agent
+
+import (
+ "os"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ Model: "test-model",
+ MaxTokens: 1234,
+ MaxToolIterations: 5,
+ },
+ },
+ }
+
+ configuredTemp := 1.0
+ cfg.Agents.Defaults.Temperature = &configuredTemp
+
+ provider := &mockProvider{}
+ agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
+
+ if agent.MaxTokens != 1234 {
+ t.Fatalf("MaxTokens = %d, want %d", agent.MaxTokens, 1234)
+ }
+ if agent.Temperature != 1.0 {
+ t.Fatalf("Temperature = %f, want %f", agent.Temperature, 1.0)
+ }
+}
+
+func TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ Model: "test-model",
+ MaxTokens: 1234,
+ MaxToolIterations: 5,
+ },
+ },
+ }
+
+ configuredTemp := 0.0
+ cfg.Agents.Defaults.Temperature = &configuredTemp
+
+ provider := &mockProvider{}
+ agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
+
+ if agent.Temperature != 0.0 {
+ t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.0)
+ }
+}
+
+func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ Model: "test-model",
+ MaxTokens: 1234,
+ MaxToolIterations: 5,
+ },
+ },
+ }
+
+ provider := &mockProvider{}
+ agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
+
+ if agent.Temperature != 0.7 {
+ t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
+ }
+}
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index f3dd94090..e7b48d47a 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -10,8 +10,6 @@ import (
"context"
"encoding/json"
"fmt"
- "os"
- "path/filepath"
"strings"
"sync"
"sync/atomic"
@@ -19,11 +17,12 @@ import (
"unicode/utf8"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
- "github.com/sipeed/picoclaw/pkg/session"
+ "github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
@@ -31,17 +30,13 @@ import (
type AgentLoop struct {
bus *bus.MessageBus
- provider providers.LLMProvider
- workspace string
- model string
- contextWindow int // Maximum context window size in tokens
- maxIterations int
- sessions *session.SessionManager
+ cfg *config.Config
+ registry *AgentRegistry
state *state.Manager
- contextBuilder *ContextBuilder
- tools *tools.ToolRegistry
running atomic.Bool
- summarizing sync.Map // Tracks which sessions are currently being summarized
+ summarizing sync.Map
+ fallback *providers.FallbackChain
+ channelManager *channels.Manager
}
// processOptions configures how a message is processed
@@ -56,96 +51,84 @@ type processOptions struct {
NoHistory bool // If true, don't load session history (for heartbeat)
}
-// createToolRegistry creates a tool registry with common tools.
-// This is shared between main agent and subagents.
-func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msgBus *bus.MessageBus) *tools.ToolRegistry {
- registry := tools.NewToolRegistry()
-
- // File system tools
- registry.Register(tools.NewReadFileTool(workspace, restrict))
- registry.Register(tools.NewWriteFileTool(workspace, restrict))
- registry.Register(tools.NewListDirTool(workspace, restrict))
- registry.Register(tools.NewEditFileTool(workspace, restrict))
- registry.Register(tools.NewAppendFileTool(workspace, restrict))
-
- // Shell execution
- registry.Register(tools.NewExecTool(workspace, restrict))
-
- if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
- BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
- BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
- BraveEnabled: cfg.Tools.Web.Brave.Enabled,
- DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
- DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
- }); searchTool != nil {
- registry.Register(searchTool)
- }
- registry.Register(tools.NewWebFetchTool(50000))
-
- // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
- registry.Register(tools.NewI2CTool())
- registry.Register(tools.NewSPITool())
-
- // Message tool - available to both agent and subagent
- // Subagent uses it to communicate directly with user
- messageTool := tools.NewMessageTool()
- messageTool.SetSendCallback(func(channel, chatID, content string) error {
- msgBus.PublishOutbound(bus.OutboundMessage{
- Channel: channel,
- ChatID: chatID,
- Content: content,
- })
- return nil
- })
- registry.Register(messageTool)
-
- return registry
-}
-
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
- workspace := cfg.WorkspacePath()
- os.MkdirAll(workspace, 0755)
+ registry := NewAgentRegistry(cfg, provider)
- restrict := cfg.Agents.Defaults.RestrictToWorkspace
+ // Register shared tools to all agents
+ registerSharedTools(cfg, msgBus, registry, provider)
- // Create tool registry for main agent
- toolsRegistry := createToolRegistry(workspace, restrict, cfg, msgBus)
+ // Set up shared fallback chain
+ cooldown := providers.NewCooldownTracker()
+ fallbackChain := providers.NewFallbackChain(cooldown)
- // Create subagent manager with its own tool registry
- subagentManager := tools.NewSubagentManager(provider, cfg.Agents.Defaults.Model, workspace, msgBus)
- subagentTools := createToolRegistry(workspace, restrict, cfg, msgBus)
- // Subagent doesn't need spawn/subagent tools to avoid recursion
- subagentManager.SetTools(subagentTools)
-
- // Register spawn tool (for main agent)
- spawnTool := tools.NewSpawnTool(subagentManager)
- toolsRegistry.Register(spawnTool)
-
- // Register subagent tool (synchronous execution)
- subagentTool := tools.NewSubagentTool(subagentManager)
- toolsRegistry.Register(subagentTool)
-
- sessionsManager := session.NewSessionManager(filepath.Join(workspace, "sessions"))
-
- // Create state manager for atomic state persistence
- stateManager := state.NewManager(workspace)
-
- // Create context builder and set tools registry
- contextBuilder := NewContextBuilder(workspace)
- contextBuilder.SetToolsRegistry(toolsRegistry)
+ // Create state manager using default agent's workspace for channel recording
+ defaultAgent := registry.GetDefaultAgent()
+ var stateManager *state.Manager
+ if defaultAgent != nil {
+ stateManager = state.NewManager(defaultAgent.Workspace)
+ }
return &AgentLoop{
- bus: msgBus,
- provider: provider,
- workspace: workspace,
- model: cfg.Agents.Defaults.Model,
- contextWindow: cfg.Agents.Defaults.MaxTokens, // Restore context window for summarization
- maxIterations: cfg.Agents.Defaults.MaxToolIterations,
- sessions: sessionsManager,
- state: stateManager,
- contextBuilder: contextBuilder,
- tools: toolsRegistry,
- summarizing: sync.Map{},
+ bus: msgBus,
+ cfg: cfg,
+ registry: registry,
+ state: stateManager,
+ summarizing: sync.Map{},
+ fallback: fallbackChain,
+ }
+}
+
+// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
+func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *AgentRegistry, provider providers.LLMProvider) {
+ for _, agentID := range registry.ListAgentIDs() {
+ agent, ok := registry.GetAgent(agentID)
+ if !ok {
+ continue
+ }
+
+ // Web tools
+ if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
+ BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
+ BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
+ BraveEnabled: cfg.Tools.Web.Brave.Enabled,
+ DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
+ DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
+ PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
+ PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
+ PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
+ }); searchTool != nil {
+ agent.Tools.Register(searchTool)
+ }
+ agent.Tools.Register(tools.NewWebFetchTool(50000))
+
+ // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
+ agent.Tools.Register(tools.NewI2CTool())
+ agent.Tools.Register(tools.NewSPITool())
+
+ // Message tool
+ messageTool := tools.NewMessageTool()
+ messageTool.SetSendCallback(func(channel, chatID, content string) error {
+ msgBus.PublishOutbound(bus.OutboundMessage{
+ Channel: channel,
+ ChatID: chatID,
+ Content: content,
+ })
+ return nil
+ })
+ agent.Tools.Register(messageTool)
+
+ // Spawn tool with allowlist checker
+ subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
+ subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
+ spawnTool := tools.NewSpawnTool(subagentManager)
+ currentAgentID := agentID
+ spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
+ return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
+ })
+ agent.Tools.Register(spawnTool)
+
+ // Update context builder with the complete tools registry
+ agent.ContextBuilder.SetToolsRegistry(agent.Tools)
}
}
@@ -170,10 +153,14 @@ func (al *AgentLoop) Run(ctx context.Context) error {
if response != "" {
// Check if the message tool already sent a response during this round.
// If so, skip publishing to avoid duplicate messages to the user.
+ // Use default agent's tools to check (message tool is shared).
alreadySent := false
- if tool, ok := al.tools.Get("message"); ok {
- if mt, ok := tool.(*tools.MessageTool); ok {
- alreadySent = mt.HasSentInRound()
+ defaultAgent := al.registry.GetDefaultAgent()
+ if defaultAgent != nil {
+ if tool, ok := defaultAgent.Tools.Get("message"); ok {
+ if mt, ok := tool.(*tools.MessageTool); ok {
+ alreadySent = mt.HasSentInRound()
+ }
}
}
@@ -196,18 +183,32 @@ func (al *AgentLoop) Stop() {
}
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
- al.tools.Register(tool)
+ for _, agentID := range al.registry.ListAgentIDs() {
+ if agent, ok := al.registry.GetAgent(agentID); ok {
+ agent.Tools.Register(tool)
+ }
+ }
+}
+
+func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
+ al.channelManager = cm
}
// RecordLastChannel records the last active channel for this workspace.
// This uses the atomic state save mechanism to prevent data loss on crash.
func (al *AgentLoop) RecordLastChannel(channel string) error {
+ if al.state == nil {
+ return nil
+ }
return al.state.SetLastChannel(channel)
}
// RecordLastChatID records the last active chat ID for this workspace.
// This uses the atomic state save mechanism to prevent data loss on crash.
func (al *AgentLoop) RecordLastChatID(chatID string) error {
+ if al.state == nil {
+ return nil
+ }
return al.state.SetLastChatID(chatID)
}
@@ -230,7 +231,8 @@ func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sess
// ProcessHeartbeat processes a heartbeat request without session history.
// Each heartbeat is independent and doesn't accumulate context.
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
- return al.runAgentLoop(ctx, processOptions{
+ agent := al.registry.GetDefaultAgent()
+ return al.runAgentLoop(ctx, agent, processOptions{
SessionKey: "heartbeat",
Channel: channel,
ChatID: chatID,
@@ -263,9 +265,41 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return al.processSystemMessage(ctx, msg)
}
- // Process as user message
- return al.runAgentLoop(ctx, processOptions{
- SessionKey: msg.SessionKey,
+ // Check for commands
+ if response, handled := al.handleCommand(ctx, msg); handled {
+ return response, nil
+ }
+
+ // Route to determine agent and session key
+ route := al.registry.ResolveRoute(routing.RouteInput{
+ Channel: msg.Channel,
+ AccountID: msg.Metadata["account_id"],
+ Peer: extractPeer(msg),
+ ParentPeer: extractParentPeer(msg),
+ GuildID: msg.Metadata["guild_id"],
+ TeamID: msg.Metadata["team_id"],
+ })
+
+ agent, ok := al.registry.GetAgent(route.AgentID)
+ if !ok {
+ agent = al.registry.GetDefaultAgent()
+ }
+
+ // Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron)
+ sessionKey := route.SessionKey
+ if msg.SessionKey != "" && strings.HasPrefix(msg.SessionKey, "agent:") {
+ sessionKey = msg.SessionKey
+ }
+
+ logger.InfoCF("agent", "Routed message",
+ map[string]interface{}{
+ "agent_id": agent.ID,
+ "session_key": sessionKey,
+ "matched_by": route.MatchedBy,
+ })
+
+ return al.runAgentLoop(ctx, agent, processOptions{
+ SessionKey: sessionKey,
Channel: msg.Channel,
ChatID: msg.ChatID,
UserMessage: msg.Content,
@@ -276,7 +310,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
- // Verify this is a system message
if msg.Channel != "system" {
return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel)
}
@@ -288,12 +321,13 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
})
// Parse origin channel from chat_id (format: "channel:chat_id")
- var originChannel string
+ var originChannel, originChatID string
if idx := strings.Index(msg.ChatID, ":"); idx > 0 {
originChannel = msg.ChatID[:idx]
+ originChatID = msg.ChatID[idx+1:]
} else {
- // Fallback
originChannel = "cli"
+ originChatID = msg.ChatID
}
// Extract subagent result from message content
@@ -314,44 +348,47 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
return "", nil
}
- // Agent acts as dispatcher only - subagent handles user interaction via message tool
- // Don't forward result here, subagent should use message tool to communicate with user
- logger.InfoCF("agent", "Subagent completed",
- map[string]interface{}{
- "sender_id": msg.SenderID,
- "channel": originChannel,
- "content_len": len(content),
- })
+ // Use default agent for system messages
+ agent := al.registry.GetDefaultAgent()
- // Agent only logs, does not respond to user
- return "", nil
+ // Use the origin session for context
+ sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
+
+ return al.runAgentLoop(ctx, agent, processOptions{
+ SessionKey: sessionKey,
+ Channel: originChannel,
+ ChatID: originChatID,
+ UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content),
+ DefaultResponse: "Background task completed.",
+ EnableSummary: false,
+ SendResponse: true,
+ })
}
// runAgentLoop is the core message processing logic.
-// It handles context building, LLM calls, tool execution, and response handling.
-func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (string, error) {
+func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opts processOptions) (string, error) {
// 0. Record last channel for heartbeat notifications (skip internal channels)
if opts.Channel != "" && opts.ChatID != "" {
// Don't record internal channels (cli, system, subagent)
if !constants.IsInternalChannel(opts.Channel) {
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
if err := al.RecordLastChannel(channelKey); err != nil {
- logger.WarnCF("agent", "Failed to record last channel: %v", map[string]interface{}{"error": err.Error()})
+ logger.WarnCF("agent", "Failed to record last channel", map[string]interface{}{"error": err.Error()})
}
}
}
// 1. Update tool contexts
- al.updateToolContexts(opts.Channel, opts.ChatID)
+ al.updateToolContexts(agent, opts.Channel, opts.ChatID)
// 2. Build messages (skip history for heartbeat)
var history []providers.Message
var summary string
if !opts.NoHistory {
- history = al.sessions.GetHistory(opts.SessionKey)
- summary = al.sessions.GetSummary(opts.SessionKey)
+ history = agent.Sessions.GetHistory(opts.SessionKey)
+ summary = agent.Sessions.GetSummary(opts.SessionKey)
}
- messages := al.contextBuilder.BuildMessages(
+ messages := agent.ContextBuilder.BuildMessages(
history,
summary,
opts.UserMessage,
@@ -361,10 +398,10 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
)
// 3. Save user message to session
- al.sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
+ agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
// 4. Run LLM iteration loop
- finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts)
+ finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
if err != nil {
return "", err
}
@@ -378,12 +415,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
}
// 6. Save final assistant message to session
- al.sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
- al.sessions.Save(opts.SessionKey)
+ agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
+ agent.Sessions.Save(opts.SessionKey)
// 7. Optional: summarization
if opts.EnableSummary {
- al.maybeSummarize(opts.SessionKey)
+ al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
}
// 8. Optional: send response via bus
@@ -399,6 +436,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
responsePreview := utils.Truncate(finalContent, 120)
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
map[string]interface{}{
+ "agent_id": agent.ID,
"session_key": opts.SessionKey,
"iterations": iteration,
"final_length": len(finalContent),
@@ -408,32 +446,33 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
}
// runLLMIteration executes the LLM call loop with tool handling.
-// Returns the final content, iteration count, and any error.
-func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.Message, opts processOptions) (string, int, error) {
+func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, messages []providers.Message, opts processOptions) (string, int, error) {
iteration := 0
var finalContent string
- for iteration < al.maxIterations {
+ for iteration < agent.MaxIterations {
iteration++
logger.DebugCF("agent", "LLM iteration",
map[string]interface{}{
+ "agent_id": agent.ID,
"iteration": iteration,
- "max": al.maxIterations,
+ "max": agent.MaxIterations,
})
// Build tool definitions
- providerToolDefs := al.tools.ToProviderDefs()
+ providerToolDefs := agent.Tools.ToProviderDefs()
// Log LLM request details
logger.DebugCF("agent", "LLM request",
map[string]interface{}{
+ "agent_id": agent.ID,
"iteration": iteration,
- "model": al.model,
+ "model": agent.Model,
"messages_count": len(messages),
"tools_count": len(providerToolDefs),
- "max_tokens": 8192,
- "temperature": 0.7,
+ "max_tokens": agent.MaxTokens,
+ "temperature": agent.Temperature,
"system_prompt_len": len(messages[0].Content),
})
@@ -445,19 +484,84 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
"tools_json": formatToolsForLog(providerToolDefs),
})
- // Call LLM
- response, err := al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{
- "max_tokens": 8192,
- "temperature": 0.7,
- })
+ // Call LLM with fallback chain if candidates are configured.
+ var response *providers.LLMResponse
+ var err error
+
+ callLLM := func() (*providers.LLMResponse, error) {
+ if len(agent.Candidates) > 1 && al.fallback != nil {
+ fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
+ func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
+ return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]interface{}{
+ "max_tokens": agent.MaxTokens,
+ "temperature": agent.Temperature,
+ })
+ },
+ )
+ if fbErr != nil {
+ return nil, fbErr
+ }
+ if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
+ logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
+ fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
+ map[string]interface{}{"agent_id": agent.ID, "iteration": iteration})
+ }
+ return fbResult.Response, nil
+ }
+ return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]interface{}{
+ "max_tokens": agent.MaxTokens,
+ "temperature": agent.Temperature,
+ })
+ }
+
+ // Retry loop for context/token errors
+ maxRetries := 2
+ for retry := 0; retry <= maxRetries; retry++ {
+ response, err = callLLM()
+ if err == nil {
+ break
+ }
+
+ errMsg := strings.ToLower(err.Error())
+ isContextError := strings.Contains(errMsg, "token") ||
+ strings.Contains(errMsg, "context") ||
+ strings.Contains(errMsg, "invalidparameter") ||
+ strings.Contains(errMsg, "length")
+
+ if isContextError && retry < maxRetries {
+ logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]interface{}{
+ "error": err.Error(),
+ "retry": retry,
+ })
+
+ if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
+ al.bus.PublishOutbound(bus.OutboundMessage{
+ Channel: opts.Channel,
+ ChatID: opts.ChatID,
+ Content: "Context window exceeded. Compressing history and retrying...",
+ })
+ }
+
+ al.forceCompression(agent, opts.SessionKey)
+ newHistory := agent.Sessions.GetHistory(opts.SessionKey)
+ newSummary := agent.Sessions.GetSummary(opts.SessionKey)
+ messages = agent.ContextBuilder.BuildMessages(
+ newHistory, newSummary, "",
+ nil, opts.Channel, opts.ChatID,
+ )
+ continue
+ }
+ break
+ }
if err != nil {
logger.ErrorCF("agent", "LLM call failed",
map[string]interface{}{
+ "agent_id": agent.ID,
"iteration": iteration,
"error": err.Error(),
})
- return "", iteration, fmt.Errorf("LLM call failed: %w", err)
+ return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
}
// Check if no tool calls - we're done
@@ -465,21 +569,28 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
finalContent = response.Content
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
map[string]interface{}{
+ "agent_id": agent.ID,
"iteration": iteration,
"content_chars": len(finalContent),
})
break
}
- // Log tool calls
- toolNames := make([]string, 0, len(response.ToolCalls))
+ normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))
for _, tc := range response.ToolCalls {
+ normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc))
+ }
+
+ // Log tool calls
+ toolNames := make([]string, 0, len(normalizedToolCalls))
+ for _, tc := range normalizedToolCalls {
toolNames = append(toolNames, tc.Name)
}
logger.InfoCF("agent", "LLM requested tool calls",
map[string]interface{}{
+ "agent_id": agent.ID,
"tools": toolNames,
- "count": len(response.ToolCalls),
+ "count": len(normalizedToolCalls),
"iteration": iteration,
})
@@ -488,29 +599,40 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
Role: "assistant",
Content: response.Content,
}
- for _, tc := range response.ToolCalls {
+ for _, tc := range normalizedToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments)
+ // Copy ExtraContent to ensure thought_signature is persisted for Gemini 3
+ extraContent := tc.ExtraContent
+ thoughtSignature := ""
+ if tc.Function != nil {
+ thoughtSignature = tc.Function.ThoughtSignature
+ }
+
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
ID: tc.ID,
Type: "function",
+ Name: tc.Name,
Function: &providers.FunctionCall{
- Name: tc.Name,
- Arguments: string(argumentsJSON),
+ Name: tc.Name,
+ Arguments: string(argumentsJSON),
+ ThoughtSignature: thoughtSignature,
},
+ ExtraContent: extraContent,
+ ThoughtSignature: thoughtSignature,
})
}
messages = append(messages, assistantMsg)
// Save assistant message with tool calls to session
- al.sessions.AddFullMessage(opts.SessionKey, assistantMsg)
+ agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
// Execute tool calls
- for _, tc := range response.ToolCalls {
- // Log tool call with arguments preview
+ for _, tc := range normalizedToolCalls {
argsJSON, _ := json.Marshal(tc.Arguments)
argsPreview := utils.Truncate(string(argsJSON), 200)
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
map[string]interface{}{
+ "agent_id": agent.ID,
"tool": tc.Name,
"iteration": iteration,
})
@@ -531,7 +653,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
}
}
- toolResult := al.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
+ toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
// Send ForUser content to user immediately if not Silent
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
@@ -561,7 +683,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
messages = append(messages, toolResultMsg)
// Save tool result message to session
- al.sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
+ agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
}
}
@@ -569,19 +691,19 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
}
// updateToolContexts updates the context for tools that need channel/chatID info.
-func (al *AgentLoop) updateToolContexts(channel, chatID string) {
+func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
// Use ContextualTool interface instead of type assertions
- if tool, ok := al.tools.Get("message"); ok {
+ if tool, ok := agent.Tools.Get("message"); ok {
if mt, ok := tool.(tools.ContextualTool); ok {
mt.SetContext(channel, chatID)
}
}
- if tool, ok := al.tools.Get("spawn"); ok {
+ if tool, ok := agent.Tools.Get("spawn"); ok {
if st, ok := tool.(tools.ContextualTool); ok {
st.SetContext(channel, chatID)
}
}
- if tool, ok := al.tools.Get("subagent"); ok {
+ if tool, ok := agent.Tools.Get("subagent"); ok {
if st, ok := tool.(tools.ContextualTool); ok {
st.SetContext(channel, chatID)
}
@@ -589,34 +711,103 @@ func (al *AgentLoop) updateToolContexts(channel, chatID string) {
}
// maybeSummarize triggers summarization if the session history exceeds thresholds.
-func (al *AgentLoop) maybeSummarize(sessionKey string) {
- newHistory := al.sessions.GetHistory(sessionKey)
+func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
+ newHistory := agent.Sessions.GetHistory(sessionKey)
tokenEstimate := al.estimateTokens(newHistory)
- threshold := al.contextWindow * 75 / 100
+ threshold := agent.ContextWindow * 75 / 100
if len(newHistory) > 20 || tokenEstimate > threshold {
- if _, loading := al.summarizing.LoadOrStore(sessionKey, true); !loading {
+ summarizeKey := agent.ID + ":" + sessionKey
+ if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
go func() {
- defer al.summarizing.Delete(sessionKey)
- al.summarizeSession(sessionKey)
+ defer al.summarizing.Delete(summarizeKey)
+ if !constants.IsInternalChannel(channel) {
+ al.bus.PublishOutbound(bus.OutboundMessage{
+ Channel: channel,
+ ChatID: chatID,
+ Content: "Memory threshold reached. Optimizing conversation history...",
+ })
+ }
+ al.summarizeSession(agent, sessionKey)
}()
}
}
}
+// forceCompression aggressively reduces context when the limit is hit.
+// It drops the oldest 50% of messages (keeping system prompt and last user message).
+func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
+ history := agent.Sessions.GetHistory(sessionKey)
+ if len(history) <= 4 {
+ return
+ }
+
+ // Keep system prompt (usually [0]) and the very last message (user's trigger)
+ // We want to drop the oldest half of the *conversation*
+ // Assuming [0] is system, [1:] is conversation
+ conversation := history[1 : len(history)-1]
+ if len(conversation) == 0 {
+ return
+ }
+
+ // Helper to find the mid-point of the conversation
+ mid := len(conversation) / 2
+
+ // New history structure:
+ // 1. System Prompt (with compression note appended)
+ // 2. Second half of conversation
+ // 3. Last message
+
+ droppedCount := mid
+ keptConversation := conversation[mid:]
+
+ newHistory := make([]providers.Message, 0)
+
+ // Append compression note to the original system prompt instead of adding a new system message
+ // This avoids having two consecutive system messages which some APIs (like Zhipu) reject
+ compressionNote := fmt.Sprintf("\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]", droppedCount)
+ enhancedSystemPrompt := history[0]
+ enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote
+ newHistory = append(newHistory, enhancedSystemPrompt)
+
+ newHistory = append(newHistory, keptConversation...)
+ newHistory = append(newHistory, history[len(history)-1]) // Last message
+
+ // Update session
+ agent.Sessions.SetHistory(sessionKey, newHistory)
+ agent.Sessions.Save(sessionKey)
+
+ logger.WarnCF("agent", "Forced compression executed", map[string]interface{}{
+ "session_key": sessionKey,
+ "dropped_msgs": droppedCount,
+ "new_count": len(newHistory),
+ })
+}
+
// GetStartupInfo returns information about loaded tools and skills for logging.
func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
info := make(map[string]interface{})
+ agent := al.registry.GetDefaultAgent()
+ if agent == nil {
+ return info
+ }
+
// Tools info
- tools := al.tools.List()
+ toolsList := agent.Tools.List()
info["tools"] = map[string]interface{}{
- "count": len(tools),
- "names": tools,
+ "count": len(toolsList),
+ "names": toolsList,
}
// Skills info
- info["skills"] = al.contextBuilder.GetSkillsInfo()
+ info["skills"] = agent.ContextBuilder.GetSkillsInfo()
+
+ // Agents info
+ info["agents"] = map[string]interface{}{
+ "count": len(al.registry.ListAgentIDs()),
+ "ids": al.registry.ListAgentIDs(),
+ }
return info
}
@@ -631,7 +822,7 @@ func formatMessagesForLog(messages []providers.Message) string {
result += "[\n"
for i, msg := range messages {
result += fmt.Sprintf(" [%d] Role: %s\n", i, msg.Role)
- if msg.ToolCalls != nil && len(msg.ToolCalls) > 0 {
+ if len(msg.ToolCalls) > 0 {
result += " ToolCalls:\n"
for _, tc := range msg.ToolCalls {
result += fmt.Sprintf(" - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
@@ -673,12 +864,12 @@ func formatToolsForLog(tools []providers.ToolDefinition) string {
}
// summarizeSession summarizes the conversation history for a session.
-func (al *AgentLoop) summarizeSession(sessionKey string) {
+func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
- history := al.sessions.GetHistory(sessionKey)
- summary := al.sessions.GetSummary(sessionKey)
+ history := agent.Sessions.GetHistory(sessionKey)
+ summary := agent.Sessions.GetSummary(sessionKey)
// Keep last 4 messages for continuity
if len(history) <= 4 {
@@ -688,8 +879,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
toSummarize := history[:len(history)-4]
// Oversized Message Guard
- // Skip messages larger than 50% of context window to prevent summarizer overflow
- maxMessageTokens := al.contextWindow / 2
+ maxMessageTokens := agent.ContextWindow / 2
validMessages := make([]providers.Message, 0)
omitted := false
@@ -697,8 +887,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
if m.Role != "user" && m.Role != "assistant" {
continue
}
- // Estimate tokens for this message
- msgTokens := len(m.Content) / 4
+ msgTokens := len(m.Content) / 2
if msgTokens > maxMessageTokens {
omitted = true
continue
@@ -711,19 +900,17 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
}
// Multi-Part Summarization
- // Split into two parts if history is significant
var finalSummary string
if len(validMessages) > 10 {
mid := len(validMessages) / 2
part1 := validMessages[:mid]
part2 := validMessages[mid:]
- s1, _ := al.summarizeBatch(ctx, part1, "")
- s2, _ := al.summarizeBatch(ctx, part2, "")
+ s1, _ := al.summarizeBatch(ctx, agent, part1, "")
+ s2, _ := al.summarizeBatch(ctx, agent, part2, "")
- // Merge them
mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2)
- resp, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, al.model, map[string]interface{}{
+ resp, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, agent.Model, map[string]interface{}{
"max_tokens": 1024,
"temperature": 0.3,
})
@@ -733,7 +920,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
finalSummary = s1 + " " + s2
}
} else {
- finalSummary, _ = al.summarizeBatch(ctx, validMessages, summary)
+ finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)
}
if omitted && finalSummary != "" {
@@ -741,14 +928,14 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
}
if finalSummary != "" {
- al.sessions.SetSummary(sessionKey, finalSummary)
- al.sessions.TruncateHistory(sessionKey, 4)
- al.sessions.Save(sessionKey)
+ agent.Sessions.SetSummary(sessionKey, finalSummary)
+ agent.Sessions.TruncateHistory(sessionKey, 4)
+ agent.Sessions.Save(sessionKey)
}
}
// summarizeBatch summarizes a batch of messages.
-func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Message, existingSummary string) (string, error) {
+func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, batch []providers.Message, existingSummary string) (string, error) {
prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n"
if existingSummary != "" {
prompt += "Existing context: " + existingSummary + "\n"
@@ -758,7 +945,7 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Messa
prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content)
}
- response, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, al.model, map[string]interface{}{
+ response, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, agent.Model, map[string]interface{}{
"max_tokens": 1024,
"temperature": 0.3,
})
@@ -769,13 +956,130 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Messa
}
// estimateTokens estimates the number of tokens in a message list.
-// Uses rune count instead of byte length so that CJK and other multi-byte
-// characters are not over-counted (a Chinese character is 3 bytes but roughly
-// one token).
+// Uses a safe heuristic of 2.5 characters per token to account for CJK and other
+// overheads better than the previous 3 chars/token.
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
- total := 0
+ totalChars := 0
for _, m := range messages {
- total += utf8.RuneCountInString(m.Content) / 3
+ totalChars += utf8.RuneCountInString(m.Content)
}
- return total
+ // 2.5 chars per token = totalChars * 2 / 5
+ return totalChars * 2 / 5
+}
+
+func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {
+ content := strings.TrimSpace(msg.Content)
+ if !strings.HasPrefix(content, "/") {
+ return "", false
+ }
+
+ parts := strings.Fields(content)
+ if len(parts) == 0 {
+ return "", false
+ }
+
+ cmd := parts[0]
+ args := parts[1:]
+
+ switch cmd {
+ case "/show":
+ if len(args) < 1 {
+ return "Usage: /show [model|channel|agents]", true
+ }
+ switch args[0] {
+ case "model":
+ defaultAgent := al.registry.GetDefaultAgent()
+ if defaultAgent == nil {
+ return "No default agent configured", true
+ }
+ return fmt.Sprintf("Current model: %s", defaultAgent.Model), true
+ case "channel":
+ return fmt.Sprintf("Current channel: %s", msg.Channel), true
+ case "agents":
+ agentIDs := al.registry.ListAgentIDs()
+ return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
+ default:
+ return fmt.Sprintf("Unknown show target: %s", args[0]), true
+ }
+
+ case "/list":
+ if len(args) < 1 {
+ return "Usage: /list [models|channels|agents]", true
+ }
+ switch args[0] {
+ case "models":
+ return "Available models: configured in config.json per agent", true
+ case "channels":
+ if al.channelManager == nil {
+ return "Channel manager not initialized", true
+ }
+ channels := al.channelManager.GetEnabledChannels()
+ if len(channels) == 0 {
+ return "No channels enabled", true
+ }
+ return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true
+ case "agents":
+ agentIDs := al.registry.ListAgentIDs()
+ return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
+ default:
+ return fmt.Sprintf("Unknown list target: %s", args[0]), true
+ }
+
+ case "/switch":
+ if len(args) < 3 || args[1] != "to" {
+ return "Usage: /switch [model|channel] to ", true
+ }
+ target := args[0]
+ value := args[2]
+
+ switch target {
+ case "model":
+ defaultAgent := al.registry.GetDefaultAgent()
+ if defaultAgent == nil {
+ return "No default agent configured", true
+ }
+ oldModel := defaultAgent.Model
+ defaultAgent.Model = value
+ return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true
+ case "channel":
+ if al.channelManager == nil {
+ return "Channel manager not initialized", true
+ }
+ if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
+ return fmt.Sprintf("Channel '%s' not found or not enabled", value), true
+ }
+ return fmt.Sprintf("Switched target channel to %s", value), true
+ default:
+ return fmt.Sprintf("Unknown switch target: %s", target), true
+ }
+ }
+
+ return "", false
+}
+
+// extractPeer extracts the routing peer from inbound message metadata.
+func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
+ peerKind := msg.Metadata["peer_kind"]
+ if peerKind == "" {
+ return nil
+ }
+ peerID := msg.Metadata["peer_id"]
+ if peerID == "" {
+ if peerKind == "direct" {
+ peerID = msg.SenderID
+ } else {
+ peerID = msg.ChatID
+ }
+ }
+ return &routing.RoutePeer{Kind: peerKind, ID: peerID}
+}
+
+// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
+func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
+ parentKind := msg.Metadata["parent_peer_kind"]
+ parentID := msg.Metadata["parent_peer_id"]
+ if parentKind == "" || parentID == "" {
+ return nil
+ }
+ return &routing.RoutePeer{Kind: parentKind, ID: parentID}
}
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index c18220258..360685eca 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -2,6 +2,7 @@ package agent
import (
"context"
+ "fmt"
"os"
"path/filepath"
"testing"
@@ -13,20 +14,6 @@ import (
"github.com/sipeed/picoclaw/pkg/tools"
)
-// mockProvider is a simple mock LLM provider for testing
-type mockProvider struct{}
-
-func (m *mockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
- return &providers.LLMResponse{
- Content: "Mock response",
- ToolCalls: []providers.ToolCall{},
- }, nil
-}
-
-func (m *mockProvider) GetDefaultModel() string {
- return "mock-model"
-}
-
func TestRecordLastChannel(t *testing.T) {
// Create temp workspace
tmpDir, err := os.MkdirTemp("", "agent-test-*")
@@ -527,3 +514,102 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {
t.Errorf("Expected 'Command output: hello world', got: %s", response)
}
}
+
+// failFirstMockProvider fails on the first N calls with a specific error
+type failFirstMockProvider struct {
+ failures int
+ currentCall int
+ failError error
+ successResp string
+}
+
+func (m *failFirstMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
+ m.currentCall++
+ if m.currentCall <= m.failures {
+ return nil, m.failError
+ }
+ return &providers.LLMResponse{
+ Content: m.successResp,
+ ToolCalls: []providers.ToolCall{},
+ }, nil
+}
+
+func (m *failFirstMockProvider) GetDefaultModel() string {
+ return "mock-fail-model"
+}
+
+// TestAgentLoop_ContextExhaustionRetry verify that the agent retries on context errors
+func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "agent-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: tmpDir,
+ Model: "test-model",
+ MaxTokens: 4096,
+ MaxToolIterations: 10,
+ },
+ },
+ }
+
+ msgBus := bus.NewMessageBus()
+
+ // Create a provider that fails once with a context error
+ contextErr := fmt.Errorf("InvalidParameter: Total tokens of image and text exceed max message tokens")
+ provider := &failFirstMockProvider{
+ failures: 1,
+ failError: contextErr,
+ successResp: "Recovered from context error",
+ }
+
+ al := NewAgentLoop(cfg, msgBus, provider)
+
+ // Inject some history to simulate a full context
+ sessionKey := "test-session-context"
+ // Create dummy history
+ history := []providers.Message{
+ {Role: "system", Content: "System prompt"},
+ {Role: "user", Content: "Old message 1"},
+ {Role: "assistant", Content: "Old response 1"},
+ {Role: "user", Content: "Old message 2"},
+ {Role: "assistant", Content: "Old response 2"},
+ {Role: "user", Content: "Trigger message"},
+ }
+ defaultAgent := al.registry.GetDefaultAgent()
+ if defaultAgent == nil {
+ t.Fatal("No default agent found")
+ }
+ defaultAgent.Sessions.SetHistory(sessionKey, history)
+
+ // Call ProcessDirectWithChannel
+ // Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration
+ response, err := al.ProcessDirectWithChannel(context.Background(), "Trigger message", sessionKey, "test", "test-chat")
+ if err != nil {
+ t.Fatalf("Expected success after retry, got error: %v", err)
+ }
+
+ if response != "Recovered from context error" {
+ t.Errorf("Expected 'Recovered from context error', got '%s'", response)
+ }
+
+ // We expect 2 calls: 1st failed, 2nd succeeded
+ if provider.currentCall != 2 {
+ t.Errorf("Expected 2 calls (1 fail + 1 success), got %d", provider.currentCall)
+ }
+
+ // Check final history length
+ finalHistory := defaultAgent.Sessions.GetHistory(sessionKey)
+ // We verify that the history has been modified (compressed)
+ // Original length: 6
+ // Expected behavior: compression drops ~50% of history (mid slice)
+ // We can assert that the length is NOT what it would be without compression.
+ // Without compression: 6 + 1 (new user msg) + 1 (assistant msg) = 8
+ if len(finalHistory) >= 8 {
+ t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
+ }
+}
diff --git a/pkg/agent/mock_provider_test.go b/pkg/agent/mock_provider_test.go
new file mode 100644
index 000000000..ccbecbafe
--- /dev/null
+++ b/pkg/agent/mock_provider_test.go
@@ -0,0 +1,20 @@
+package agent
+
+import (
+ "context"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+type mockProvider struct{}
+
+func (m *mockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
+ return &providers.LLMResponse{
+ Content: "Mock response",
+ ToolCalls: []providers.ToolCall{},
+ }, nil
+}
+
+func (m *mockProvider) GetDefaultModel() string {
+ return "mock-model"
+}
diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go
new file mode 100644
index 000000000..4cf5a6fca
--- /dev/null
+++ b/pkg/agent/registry.go
@@ -0,0 +1,114 @@
+package agent
+
+import (
+ "sync"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/routing"
+)
+
+// AgentRegistry manages multiple agent instances and routes messages to them.
+type AgentRegistry struct {
+ agents map[string]*AgentInstance
+ resolver *routing.RouteResolver
+ mu sync.RWMutex
+}
+
+// NewAgentRegistry creates a registry from config, instantiating all agents.
+func NewAgentRegistry(
+ cfg *config.Config,
+ provider providers.LLMProvider,
+) *AgentRegistry {
+ registry := &AgentRegistry{
+ agents: make(map[string]*AgentInstance),
+ resolver: routing.NewRouteResolver(cfg),
+ }
+
+ agentConfigs := cfg.Agents.List
+ if len(agentConfigs) == 0 {
+ implicitAgent := &config.AgentConfig{
+ ID: "main",
+ Default: true,
+ }
+ instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider)
+ registry.agents["main"] = instance
+ logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil)
+ } else {
+ for i := range agentConfigs {
+ ac := &agentConfigs[i]
+ id := routing.NormalizeAgentID(ac.ID)
+ instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
+ registry.agents[id] = instance
+ logger.InfoCF("agent", "Registered agent",
+ map[string]interface{}{
+ "agent_id": id,
+ "name": ac.Name,
+ "workspace": instance.Workspace,
+ "model": instance.Model,
+ })
+ }
+ }
+
+ return registry
+}
+
+// GetAgent returns the agent instance for a given ID.
+func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ id := routing.NormalizeAgentID(agentID)
+ agent, ok := r.agents[id]
+ return agent, ok
+}
+
+// ResolveRoute determines which agent handles the message.
+func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute {
+ return r.resolver.ResolveRoute(input)
+}
+
+// ListAgentIDs returns all registered agent IDs.
+func (r *AgentRegistry) ListAgentIDs() []string {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ ids := make([]string, 0, len(r.agents))
+ for id := range r.agents {
+ ids = append(ids, id)
+ }
+ return ids
+}
+
+// CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID.
+func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool {
+ parent, ok := r.GetAgent(parentAgentID)
+ if !ok {
+ return false
+ }
+ if parent.Subagents == nil || parent.Subagents.AllowAgents == nil {
+ return false
+ }
+ targetNorm := routing.NormalizeAgentID(targetAgentID)
+ for _, allowed := range parent.Subagents.AllowAgents {
+ if allowed == "*" {
+ return true
+ }
+ if routing.NormalizeAgentID(allowed) == targetNorm {
+ return true
+ }
+ }
+ return false
+}
+
+// GetDefaultAgent returns the default agent instance.
+func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ if agent, ok := r.agents["main"]; ok {
+ return agent
+ }
+ for _, agent := range r.agents {
+ return agent
+ }
+ return nil
+}
diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go
new file mode 100644
index 000000000..f196d7fb7
--- /dev/null
+++ b/pkg/agent/registry_test.go
@@ -0,0 +1,199 @@
+package agent
+
+import (
+ "context"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+type mockRegistryProvider struct{}
+
+func (m *mockRegistryProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) {
+ return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil
+}
+
+func (m *mockRegistryProvider) GetDefaultModel() string {
+ return "mock-model"
+}
+
+func testCfg(agents []config.AgentConfig) *config.Config {
+ return &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: "/tmp/picoclaw-test-registry",
+ Model: "gpt-4",
+ MaxTokens: 8192,
+ MaxToolIterations: 10,
+ },
+ List: agents,
+ },
+ }
+}
+
+func TestNewAgentRegistry_ImplicitMain(t *testing.T) {
+ cfg := testCfg(nil)
+ registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
+
+ ids := registry.ListAgentIDs()
+ if len(ids) != 1 || ids[0] != "main" {
+ t.Errorf("expected implicit main agent, got %v", ids)
+ }
+
+ agent, ok := registry.GetAgent("main")
+ if !ok || agent == nil {
+ t.Fatal("expected to find 'main' agent")
+ }
+ if agent.ID != "main" {
+ t.Errorf("agent.ID = %q, want 'main'", agent.ID)
+ }
+}
+
+func TestNewAgentRegistry_ExplicitAgents(t *testing.T) {
+ cfg := testCfg([]config.AgentConfig{
+ {ID: "sales", Default: true, Name: "Sales Bot"},
+ {ID: "support", Name: "Support Bot"},
+ })
+ registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
+
+ ids := registry.ListAgentIDs()
+ if len(ids) != 2 {
+ t.Fatalf("expected 2 agents, got %d: %v", len(ids), ids)
+ }
+
+ sales, ok := registry.GetAgent("sales")
+ if !ok || sales == nil {
+ t.Fatal("expected to find 'sales' agent")
+ }
+ if sales.Name != "Sales Bot" {
+ t.Errorf("sales.Name = %q, want 'Sales Bot'", sales.Name)
+ }
+
+ support, ok := registry.GetAgent("support")
+ if !ok || support == nil {
+ t.Fatal("expected to find 'support' agent")
+ }
+}
+
+func TestAgentRegistry_GetAgent_Normalize(t *testing.T) {
+ cfg := testCfg([]config.AgentConfig{
+ {ID: "my-agent", Default: true},
+ })
+ registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
+
+ agent, ok := registry.GetAgent("My-Agent")
+ if !ok || agent == nil {
+ t.Fatal("expected to find agent with normalized ID")
+ }
+ if agent.ID != "my-agent" {
+ t.Errorf("agent.ID = %q, want 'my-agent'", agent.ID)
+ }
+}
+
+func TestAgentRegistry_GetDefaultAgent(t *testing.T) {
+ cfg := testCfg([]config.AgentConfig{
+ {ID: "alpha"},
+ {ID: "beta", Default: true},
+ })
+ registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
+
+ // GetDefaultAgent first checks for "main", then returns any
+ agent := registry.GetDefaultAgent()
+ if agent == nil {
+ t.Fatal("expected a default agent")
+ }
+}
+
+func TestAgentRegistry_CanSpawnSubagent(t *testing.T) {
+ cfg := testCfg([]config.AgentConfig{
+ {
+ ID: "parent",
+ Default: true,
+ Subagents: &config.SubagentsConfig{
+ AllowAgents: []string{"child1", "child2"},
+ },
+ },
+ {ID: "child1"},
+ {ID: "child2"},
+ {ID: "restricted"},
+ })
+ registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
+
+ if !registry.CanSpawnSubagent("parent", "child1") {
+ t.Error("expected parent to be allowed to spawn child1")
+ }
+ if !registry.CanSpawnSubagent("parent", "child2") {
+ t.Error("expected parent to be allowed to spawn child2")
+ }
+ if registry.CanSpawnSubagent("parent", "restricted") {
+ t.Error("expected parent to NOT be allowed to spawn restricted")
+ }
+ if registry.CanSpawnSubagent("child1", "child2") {
+ t.Error("expected child1 to NOT be allowed to spawn (no subagents config)")
+ }
+}
+
+func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
+ cfg := testCfg([]config.AgentConfig{
+ {
+ ID: "admin",
+ Default: true,
+ Subagents: &config.SubagentsConfig{
+ AllowAgents: []string{"*"},
+ },
+ },
+ {ID: "any-agent"},
+ })
+ registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
+
+ if !registry.CanSpawnSubagent("admin", "any-agent") {
+ t.Error("expected wildcard to allow spawning any agent")
+ }
+ if !registry.CanSpawnSubagent("admin", "nonexistent") {
+ t.Error("expected wildcard to allow spawning even nonexistent agents")
+ }
+}
+
+func TestAgentInstance_Model(t *testing.T) {
+ model := &config.AgentModelConfig{Primary: "claude-opus"}
+ cfg := testCfg([]config.AgentConfig{
+ {ID: "custom", Default: true, Model: model},
+ })
+ registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
+
+ agent, _ := registry.GetAgent("custom")
+ if agent.Model != "claude-opus" {
+ t.Errorf("agent.Model = %q, want 'claude-opus'", agent.Model)
+ }
+}
+
+func TestAgentInstance_FallbackInheritance(t *testing.T) {
+ cfg := testCfg([]config.AgentConfig{
+ {ID: "inherit", Default: true},
+ })
+ cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"}
+ registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
+
+ agent, _ := registry.GetAgent("inherit")
+ if len(agent.Fallbacks) != 2 {
+ t.Errorf("expected 2 fallbacks inherited from defaults, got %d", len(agent.Fallbacks))
+ }
+}
+
+func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {
+ model := &config.AgentModelConfig{
+ Primary: "gpt-4",
+ Fallbacks: []string{}, // explicitly empty = disable
+ }
+ cfg := testCfg([]config.AgentConfig{
+ {ID: "no-fallback", Default: true, Model: model},
+ })
+ cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"}
+ registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
+
+ agent, _ := registry.GetAgent("no-fallback")
+ if len(agent.Fallbacks) != 0 {
+ t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks)
+ }
+}
diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go
index 1a6589641..4376f24d4 100644
--- a/pkg/auth/oauth.go
+++ b/pkg/auth/oauth.go
@@ -1,6 +1,7 @@
package auth
import (
+ "bufio"
"context"
"crypto/rand"
"encoding/base64"
@@ -11,6 +12,7 @@ import (
"net"
"net/http"
"net/url"
+ "os"
"os/exec"
"runtime"
"strconv"
@@ -19,11 +21,13 @@ import (
)
type OAuthProviderConfig struct {
- Issuer string
- ClientID string
- Scopes string
- Originator string
- Port int
+ Issuer string
+ ClientID string
+ ClientSecret string // Required for Google OAuth (confidential client)
+ TokenURL string // Override token endpoint (Google uses a different URL than issuer)
+ Scopes string
+ Originator string
+ Port int
}
func OpenAIOAuthConfig() OAuthProviderConfig {
@@ -36,6 +40,30 @@ func OpenAIOAuthConfig() OAuthProviderConfig {
}
}
+// GoogleAntigravityOAuthConfig returns the OAuth configuration for Google Cloud Code Assist (Antigravity).
+// Client credentials are the same ones used by OpenCode/pi-ai for Cloud Code Assist access.
+func GoogleAntigravityOAuthConfig() OAuthProviderConfig {
+ // These are the same client credentials used by the OpenCode antigravity plugin.
+ clientID := decodeBase64("MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==")
+ clientSecret := decodeBase64("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=")
+ return OAuthProviderConfig{
+ Issuer: "https://accounts.google.com/o/oauth2/v2",
+ TokenURL: "https://oauth2.googleapis.com/token",
+ ClientID: clientID,
+ ClientSecret: clientSecret,
+ Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs",
+ Port: 51121,
+ }
+}
+
+func decodeBase64(s string) string {
+ data, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ return s
+ }
+ return string(data)
+}
+
func generateState() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
@@ -101,8 +129,17 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
}
- fmt.Println("If you're running in a headless environment, use: picoclaw auth login --provider openai --device-code")
- fmt.Println("Waiting for authentication in browser...")
+ fmt.Printf("Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n", cfg.Port)
+ fmt.Println("please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.")
+ fmt.Println("Waiting for authentication (browser or manual paste)...")
+
+ // Start manual input in a goroutine
+ manualCh := make(chan string)
+ go func() {
+ reader := bufio.NewReader(os.Stdin)
+ input, _ := reader.ReadString('\n')
+ manualCh <- strings.TrimSpace(input)
+ }()
select {
case result := <-resultCh:
@@ -110,6 +147,22 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
return nil, result.err
}
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
+ case manualInput := <-manualCh:
+ if manualInput == "" {
+ return nil, fmt.Errorf("manual input cancelled")
+ }
+ // Extract code from URL if it's a full URL
+ code := manualInput
+ if strings.Contains(manualInput, "?") {
+ u, err := url.Parse(manualInput)
+ if err == nil {
+ code = u.Query().Get("code")
+ }
+ }
+ if code == "" {
+ return nil, fmt.Errorf("could not find authorization code in input")
+ }
+ return exchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
case <-time.After(5 * time.Minute):
return nil, fmt.Errorf("authentication timed out after 5 minutes")
}
@@ -269,8 +322,16 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
"refresh_token": {cred.RefreshToken},
"scope": {"openid profile email"},
}
+ if cfg.ClientSecret != "" {
+ data.Set("client_secret", cfg.ClientSecret)
+ }
- resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data)
+ tokenURL := cfg.Issuer + "/oauth/token"
+ if cfg.TokenURL != "" {
+ tokenURL = cfg.TokenURL
+ }
+
+ resp, err := http.PostForm(tokenURL, data)
if err != nil {
return nil, fmt.Errorf("refreshing token: %w", err)
}
@@ -281,7 +342,23 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
return nil, fmt.Errorf("token refresh failed: %s", string(body))
}
- return parseTokenResponse(body, cred.Provider)
+ refreshed, err := parseTokenResponse(body, cred.Provider)
+ if err != nil {
+ return nil, err
+ }
+ if refreshed.RefreshToken == "" {
+ refreshed.RefreshToken = cred.RefreshToken
+ }
+ if refreshed.AccountID == "" {
+ refreshed.AccountID = cred.AccountID
+ }
+ if cred.Email != "" && refreshed.Email == "" {
+ refreshed.Email = cred.Email
+ }
+ if cred.ProjectID != "" && refreshed.ProjectID == "" {
+ refreshed.ProjectID = cred.ProjectID
+ }
+ return refreshed, nil
}
func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
@@ -290,18 +367,35 @@ func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
params := url.Values{
- "response_type": {"code"},
- "client_id": {cfg.ClientID},
- "redirect_uri": {redirectURI},
- "scope": {cfg.Scopes},
- "code_challenge": {pkce.CodeChallenge},
- "code_challenge_method": {"S256"},
- "id_token_add_organizations": {"true"},
- "codex_cli_simplified_flow": {"true"},
- "state": {state},
+ "response_type": {"code"},
+ "client_id": {cfg.ClientID},
+ "redirect_uri": {redirectURI},
+ "scope": {cfg.Scopes},
+ "code_challenge": {pkce.CodeChallenge},
+ "code_challenge_method": {"S256"},
+ "state": {state},
}
- if cfg.Originator != "" {
- params.Set("originator", cfg.Originator)
+
+ isGoogle := strings.Contains(strings.ToLower(cfg.Issuer), "accounts.google.com")
+ if isGoogle {
+ // Google OAuth requires these for refresh token support
+ params.Set("access_type", "offline")
+ params.Set("prompt", "consent")
+ } else {
+ // OpenAI-specific parameters
+ params.Set("id_token_add_organizations", "true")
+ params.Set("codex_cli_simplified_flow", "true")
+ if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") {
+ params.Set("originator", "picoclaw")
+ }
+ if cfg.Originator != "" {
+ params.Set("originator", cfg.Originator)
+ }
+ }
+
+ // Google uses /auth path, OpenAI uses /oauth/authorize
+ if isGoogle {
+ return cfg.Issuer + "/auth?" + params.Encode()
}
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
}
@@ -314,8 +408,22 @@ func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
"client_id": {cfg.ClientID},
"code_verifier": {codeVerifier},
}
+ if cfg.ClientSecret != "" {
+ data.Set("client_secret", cfg.ClientSecret)
+ }
- resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data)
+ tokenURL := cfg.Issuer + "/oauth/token"
+ if cfg.TokenURL != "" {
+ tokenURL = cfg.TokenURL
+ }
+
+ // Determine provider name from config
+ provider := "openai"
+ if cfg.TokenURL != "" && strings.Contains(cfg.TokenURL, "googleapis.com") {
+ provider = "google-antigravity"
+ }
+
+ resp, err := http.PostForm(tokenURL, data)
if err != nil {
return nil, fmt.Errorf("exchanging code for tokens: %w", err)
}
@@ -326,7 +434,7 @@ func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
return nil, fmt.Errorf("token exchange failed: %s", string(body))
}
- return parseTokenResponse(body, "openai")
+ return parseTokenResponse(body, provider)
}
func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
@@ -357,7 +465,9 @@ func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
AuthMethod: "oauth",
}
- if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" {
+ if accountID := extractAccountID(tokenResp.IDToken); accountID != "" {
+ cred.AccountID = accountID
+ } else if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" {
cred.AccountID = accountID
} else if accountID := extractAccountID(tokenResp.IDToken); accountID != "" {
// Recent OpenAI OAuth responses may only include chatgpt_account_id in id_token claims.
@@ -367,12 +477,45 @@ func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
return cred, nil
}
-func extractAccountID(accessToken string) string {
- parts := strings.Split(accessToken, ".")
- if len(parts) < 2 {
+func extractAccountID(token string) string {
+ claims, err := parseJWTClaims(token)
+ if err != nil {
return ""
}
+ if accountID, ok := claims["chatgpt_account_id"].(string); ok && accountID != "" {
+ return accountID
+ }
+
+ if accountID, ok := claims["https://api.openai.com/auth.chatgpt_account_id"].(string); ok && accountID != "" {
+ return accountID
+ }
+
+ if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok {
+ if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" {
+ return accountID
+ }
+ }
+
+ if orgs, ok := claims["organizations"].([]interface{}); ok {
+ for _, org := range orgs {
+ if orgMap, ok := org.(map[string]interface{}); ok {
+ if accountID, ok := orgMap["id"].(string); ok && accountID != "" {
+ return accountID
+ }
+ }
+ }
+ }
+
+ return ""
+}
+
+func parseJWTClaims(token string) (map[string]interface{}, error) {
+ parts := strings.Split(token, ".")
+ if len(parts) < 2 {
+ return nil, fmt.Errorf("token is not a JWT")
+ }
+
payload := parts[1]
switch len(payload) % 4 {
case 2:
@@ -383,21 +526,15 @@ func extractAccountID(accessToken string) string {
decoded, err := base64URLDecode(payload)
if err != nil {
- return ""
+ return nil, err
}
var claims map[string]interface{}
if err := json.Unmarshal(decoded, &claims); err != nil {
- return ""
+ return nil, err
}
- if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok {
- if accountID, ok := authClaim["chatgpt_account_id"].(string); ok {
- return accountID
- }
- }
-
- return ""
+ return claims, nil
}
func base64URLDecode(s string) ([]byte, error) {
diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go
index 0d2ccc9a5..5deb17805 100644
--- a/pkg/auth/oauth_test.go
+++ b/pkg/auth/oauth_test.go
@@ -5,10 +5,23 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "net/url"
"strings"
"testing"
)
+func makeJWTForClaims(t *testing.T, claims map[string]interface{}) string {
+ t.Helper()
+
+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
+ payloadJSON, err := json.Marshal(claims)
+ if err != nil {
+ t.Fatalf("marshal claims: %v", err)
+ }
+ payload := base64.RawURLEncoding.EncodeToString(payloadJSON)
+ return header + "." + payload + ".sig"
+}
+
func TestBuildAuthorizeURL(t *testing.T) {
cfg := OAuthProviderConfig{
Issuer: "https://auth.example.com",
@@ -53,6 +66,28 @@ func TestBuildAuthorizeURL(t *testing.T) {
}
}
+func TestBuildAuthorizeURLOpenAIExtras(t *testing.T) {
+ cfg := OpenAIOAuthConfig()
+ pkce := PKCECodes{CodeVerifier: "test-verifier", CodeChallenge: "test-challenge"}
+
+ u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback")
+ parsed, err := url.Parse(u)
+ if err != nil {
+ t.Fatalf("url.Parse() error: %v", err)
+ }
+ q := parsed.Query()
+
+ if q.Get("id_token_add_organizations") != "true" {
+ t.Errorf("id_token_add_organizations = %q, want true", q.Get("id_token_add_organizations"))
+ }
+ if q.Get("codex_cli_simplified_flow") != "true" {
+ t.Errorf("codex_cli_simplified_flow = %q, want true", q.Get("codex_cli_simplified_flow"))
+ }
+ if q.Get("originator") != "codex_cli_rs" {
+ t.Errorf("originator = %q, want codex_cli_rs", q.Get("originator"))
+ }
+}
+
func TestParseTokenResponse(t *testing.T) {
resp := map[string]interface{}{
"access_token": "test-access-token",
@@ -84,6 +119,37 @@ func TestParseTokenResponse(t *testing.T) {
}
}
+func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
+ idToken := makeJWTForClaims(t, map[string]interface{}{"chatgpt_account_id": "acc-id-from-id-token"})
+ resp := map[string]interface{}{
+ "access_token": "opaque-access-token",
+ "refresh_token": "test-refresh-token",
+ "expires_in": 3600,
+ "id_token": idToken,
+ }
+ body, _ := json.Marshal(resp)
+
+ cred, err := parseTokenResponse(body, "openai")
+ if err != nil {
+ t.Fatalf("parseTokenResponse() error: %v", err)
+ }
+ if cred.AccountID != "acc-id-from-id-token" {
+ t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-id-from-id-token")
+ }
+}
+
+func TestExtractAccountIDFromOrganizationsFallback(t *testing.T) {
+ token := makeJWTForClaims(t, map[string]interface{}{
+ "organizations": []interface{}{
+ map[string]interface{}{"id": "org_from_orgs"},
+ },
+ })
+
+ if got := extractAccountID(token); got != "org_from_orgs" {
+ t.Errorf("extractAccountID() = %q, want %q", got, "org_from_orgs")
+ }
+}
+
func TestParseTokenResponseNoAccessToken(t *testing.T) {
body := []byte(`{"refresh_token": "test"}`)
_, err := parseTokenResponse(body, "openai")
@@ -222,6 +288,37 @@ func TestRefreshAccessTokenNoRefreshToken(t *testing.T) {
}
}
+func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := map[string]interface{}{
+ "access_token": "new-access-token-only",
+ "expires_in": 3600,
+ }
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ cfg := OAuthProviderConfig{Issuer: server.URL, ClientID: "test-client"}
+ cred := &AuthCredential{
+ AccessToken: "old-access",
+ RefreshToken: "existing-refresh",
+ AccountID: "acc_existing",
+ Provider: "openai",
+ AuthMethod: "oauth",
+ }
+
+ refreshed, err := RefreshAccessToken(cred, cfg)
+ if err != nil {
+ t.Fatalf("RefreshAccessToken() error: %v", err)
+ }
+ if refreshed.RefreshToken != "existing-refresh" {
+ t.Errorf("RefreshToken = %q, want %q", refreshed.RefreshToken, "existing-refresh")
+ }
+ if refreshed.AccountID != "acc_existing" {
+ t.Errorf("AccountID = %q, want %q", refreshed.AccountID, "acc_existing")
+ }
+}
+
func TestOpenAIOAuthConfig(t *testing.T) {
cfg := OpenAIOAuthConfig()
if cfg.Issuer != "https://auth.openai.com" {
diff --git a/pkg/auth/store.go b/pkg/auth/store.go
index 20724929a..785d5858e 100644
--- a/pkg/auth/store.go
+++ b/pkg/auth/store.go
@@ -14,6 +14,8 @@ type AuthCredential struct {
ExpiresAt time.Time `json:"expires_at,omitempty"`
Provider string `json:"provider"`
AuthMethod string `json:"auth_method"`
+ Email string `json:"email,omitempty"`
+ ProjectID string `json:"project_id,omitempty"`
}
type AuthStore struct {
diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go
index 6283251a4..58c0a25d5 100644
--- a/pkg/bus/bus.go
+++ b/pkg/bus/bus.go
@@ -9,6 +9,7 @@ type MessageBus struct {
inbound chan InboundMessage
outbound chan OutboundMessage
handlers map[string]MessageHandler
+ closed bool
mu sync.RWMutex
}
@@ -21,6 +22,11 @@ func NewMessageBus() *MessageBus {
}
func (mb *MessageBus) PublishInbound(msg InboundMessage) {
+ mb.mu.RLock()
+ defer mb.mu.RUnlock()
+ if mb.closed {
+ return
+ }
mb.inbound <- msg
}
@@ -34,6 +40,11 @@ func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool)
}
func (mb *MessageBus) PublishOutbound(msg OutboundMessage) {
+ mb.mu.RLock()
+ defer mb.mu.RUnlock()
+ if mb.closed {
+ return
+ }
mb.outbound <- msg
}
@@ -60,6 +71,12 @@ func (mb *MessageBus) GetHandler(channel string) (MessageHandler, bool) {
}
func (mb *MessageBus) Close() {
+ mb.mu.Lock()
+ defer mb.mu.Unlock()
+ if mb.closed {
+ return
+ }
+ mb.closed = true
close(mb.inbound)
close(mb.outbound)
}
diff --git a/pkg/channels/base.go b/pkg/channels/base.go
index 8d2d9a65b..4925099a3 100644
--- a/pkg/channels/base.go
+++ b/pkg/channels/base.go
@@ -2,7 +2,6 @@ package channels
import (
"context"
- "fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/bus"
@@ -87,17 +86,13 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st
return
}
- // Build session key: channel:chatID
- sessionKey := fmt.Sprintf("%s:%s", c.name, chatID)
-
msg := bus.InboundMessage{
- Channel: c.name,
- SenderID: senderID,
- ChatID: chatID,
- Content: content,
- Media: media,
- SessionKey: sessionKey,
- Metadata: metadata,
+ Channel: c.name,
+ SenderID: senderID,
+ ChatID: chatID,
+ Content: content,
+ Media: media,
+ Metadata: metadata,
}
c.bus.PublishInbound(msg)
diff --git a/pkg/channels/dingtalk.go b/pkg/channels/dingtalk.go
index 263785c0c..79cc85219 100644
--- a/pkg/channels/dingtalk.go
+++ b/pkg/channels/dingtalk.go
@@ -155,6 +155,14 @@ func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *ch
"session_webhook": data.SessionWebhook,
}
+ if data.ConversationType == "1" {
+ metadata["peer_kind"] = "direct"
+ metadata["peer_id"] = senderID
+ } else {
+ metadata["peer_kind"] = "group"
+ metadata["peer_id"] = data.ConversationId
+ }
+
logger.DebugCF("dingtalk", "Received message", map[string]interface{}{
"sender_nick": senderNick,
"sender_id": senderID,
diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go
index e65c99eec..9ddec662c 100644
--- a/pkg/channels/discord.go
+++ b/pkg/channels/discord.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
+ "sync"
"time"
"github.com/bwmarrin/discordgo"
@@ -25,6 +26,8 @@ type DiscordChannel struct {
config config.DiscordConfig
transcriber *voice.GroqTranscriber
ctx context.Context
+ typingMu sync.Mutex
+ typingStop map[string]chan struct{} // chatID → stop signal
}
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
@@ -41,6 +44,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
config: cfg,
transcriber: nil,
ctx: context.Background(),
+ typingStop: make(map[string]chan struct{}),
}, nil
}
@@ -83,6 +87,14 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
logger.InfoC("discord", "Stopping Discord bot")
c.setRunning(false)
+ // Stop all typing goroutines before closing session
+ c.typingMu.Lock()
+ for chatID, stop := range c.typingStop {
+ close(stop)
+ delete(c.typingStop, chatID)
+ }
+ c.typingMu.Unlock()
+
if err := c.session.Close(); err != nil {
return fmt.Errorf("failed to close discord session: %w", err)
}
@@ -91,6 +103,8 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
}
func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ c.stopTyping(msg.ChatID)
+
if !c.IsRunning() {
return fmt.Errorf("discord bot not running")
}
@@ -100,15 +114,30 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return fmt.Errorf("channel ID is empty")
}
- message := msg.Content
+ runes := []rune(msg.Content)
+ if len(runes) == 0 {
+ return nil
+ }
+ chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars
+
+ for _, chunk := range chunks {
+ if err := c.sendChunk(ctx, channelID, chunk); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
// 使用传入的 ctx 进行超时控制
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
done := make(chan error, 1)
go func() {
- _, err := c.session.ChannelMessageSend(channelID, message)
+ _, err := c.session.ChannelMessageSend(channelID, content)
done <- err
}()
@@ -222,12 +251,22 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
content = "[media only]"
}
+ // Start typing after all early returns — guaranteed to have a matching Send()
+ c.startTyping(m.ChannelID)
+
logger.DebugCF("discord", "Received message", map[string]any{
"sender_name": senderName,
"sender_id": senderID,
"preview": utils.Truncate(content, 50),
})
+ peerKind := "channel"
+ peerID := m.ChannelID
+ if m.GuildID == "" {
+ peerKind = "direct"
+ peerID = senderID
+ }
+
metadata := map[string]string{
"message_id": m.ID,
"user_id": senderID,
@@ -236,11 +275,59 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
"guild_id": m.GuildID,
"channel_id": m.ChannelID,
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
+ "peer_kind": peerKind,
+ "peer_id": peerID,
}
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
}
+// startTyping starts a continuous typing indicator loop for the given chatID.
+// It stops any existing typing loop for that chatID before starting a new one.
+func (c *DiscordChannel) startTyping(chatID string) {
+ c.typingMu.Lock()
+ // Stop existing loop for this chatID if any
+ if stop, ok := c.typingStop[chatID]; ok {
+ close(stop)
+ }
+ stop := make(chan struct{})
+ c.typingStop[chatID] = stop
+ c.typingMu.Unlock()
+
+ go func() {
+ if err := c.session.ChannelTyping(chatID); err != nil {
+ logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err})
+ }
+ ticker := time.NewTicker(8 * time.Second)
+ defer ticker.Stop()
+ timeout := time.After(5 * time.Minute)
+ for {
+ select {
+ case <-stop:
+ return
+ case <-timeout:
+ return
+ case <-c.ctx.Done():
+ return
+ case <-ticker.C:
+ if err := c.session.ChannelTyping(chatID); err != nil {
+ logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err})
+ }
+ }
+ }
+ }()
+}
+
+// stopTyping stops the typing indicator loop for the given chatID.
+func (c *DiscordChannel) stopTyping(chatID string) {
+ c.typingMu.Lock()
+ defer c.typingMu.Unlock()
+ if stop, ok := c.typingStop[chatID]; ok {
+ close(stop)
+ delete(c.typingStop, chatID)
+ }
+}
+
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
return utils.DownloadFile(url, filename, utils.DownloadOptions{
LoggerPrefix: "discord",
diff --git a/pkg/channels/feishu_64.go b/pkg/channels/feishu_64.go
index 39dc40ac1..9e15fa3a7 100644
--- a/pkg/channels/feishu_64.go
+++ b/pkg/channels/feishu_64.go
@@ -165,6 +165,15 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
metadata["tenant_key"] = *sender.TenantKey
}
+ chatType := stringValue(message.ChatType)
+ if chatType == "p2p" {
+ metadata["peer_kind"] = "direct"
+ metadata["peer_id"] = senderID
+ } else {
+ metadata["peer_kind"] = "group"
+ metadata["peer_id"] = chatID
+ }
+
logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{
"sender_id": senderID,
"chat_id": chatID,
diff --git a/pkg/channels/line.go b/pkg/channels/line.go
index ffb5533e8..9f7d2bde0 100644
--- a/pkg/channels/line.go
+++ b/pkg/channels/line.go
@@ -366,6 +366,14 @@ func (c *LINEChannel) processEvent(event lineEvent) {
"message_id": msg.ID,
}
+ if isGroup {
+ metadata["peer_kind"] = "group"
+ metadata["peer_id"] = chatID
+ } else {
+ metadata["peer_kind"] = "direct"
+ metadata["peer_id"] = senderID
+ }
+
logger.DebugCF("line", "Received message", map[string]interface{}{
"sender_id": senderID,
"chat_id": chatID,
diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam.go
index 5fc19adbe..95da0547c 100644
--- a/pkg/channels/maixcam.go
+++ b/pkg/channels/maixcam.go
@@ -18,7 +18,6 @@ type MaixCamChannel struct {
listener net.Listener
clients map[net.Conn]bool
clientsMux sync.RWMutex
- running bool
}
type MaixCamMessage struct {
@@ -35,7 +34,6 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamC
BaseChannel: base,
config: cfg,
clients: make(map[net.Conn]bool),
- running: false,
}, nil
}
@@ -172,6 +170,8 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
"y": fmt.Sprintf("%.0f", y),
"w": fmt.Sprintf("%.0f", w),
"h": fmt.Sprintf("%.0f", h),
+ "peer_kind": "channel",
+ "peer_id": "default",
}
c.HandleMessage(senderID, chatID, content, []string{}, metadata)
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 15f8c6037..7f6abc4cb 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -48,7 +48,7 @@ func (m *Manager) initChannels() error {
if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" {
logger.DebugC("channels", "Attempting to initialize Telegram channel")
- telegram, err := NewTelegramChannel(m.config.Channels.Telegram, m.bus)
+ telegram, err := NewTelegramChannel(m.config, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]interface{}{
"error": err.Error(),
diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go
index 5d97fab9c..06186f783 100644
--- a/pkg/channels/onebot.go
+++ b/pkg/channels/onebot.go
@@ -4,9 +4,11 @@ import (
"context"
"encoding/json"
"fmt"
+ "os"
"strconv"
"strings"
"sync"
+ "sync/atomic"
"time"
"github.com/gorilla/websocket"
@@ -14,20 +16,28 @@ import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/utils"
+ "github.com/sipeed/picoclaw/pkg/voice"
)
type OneBotChannel struct {
*BaseChannel
- config config.OneBotConfig
- conn *websocket.Conn
- ctx context.Context
- cancel context.CancelFunc
- dedup map[string]struct{}
- dedupRing []string
- dedupIdx int
- mu sync.Mutex
- writeMu sync.Mutex
- echoCounter int64
+ config config.OneBotConfig
+ conn *websocket.Conn
+ ctx context.Context
+ cancel context.CancelFunc
+ dedup map[string]struct{}
+ dedupRing []string
+ dedupIdx int
+ mu sync.Mutex
+ writeMu sync.Mutex
+ echoCounter int64
+ selfID int64
+ pending map[string]chan json.RawMessage
+ pendingMu sync.Mutex
+ transcriber *voice.GroqTranscriber
+ lastMessageID sync.Map
+ pendingEmojiMsg sync.Map
}
type oneBotRawEvent struct {
@@ -43,9 +53,11 @@ type oneBotRawEvent struct {
SelfID json.RawMessage `json:"self_id"`
Time json.RawMessage `json:"time"`
MetaEventType string `json:"meta_event_type"`
+ NoticeType string `json:"notice_type"`
Echo string `json:"echo"`
RetCode json.RawMessage `json:"retcode"`
- Status BotStatus `json:"status"`
+ Status json.RawMessage `json:"status"`
+ Data json.RawMessage `json:"data"`
}
type BotStatus struct {
@@ -53,42 +65,36 @@ type BotStatus struct {
Good bool `json:"good"`
}
+func isAPIResponse(raw json.RawMessage) bool {
+ if len(raw) == 0 {
+ return false
+ }
+ var s string
+ if json.Unmarshal(raw, &s) == nil {
+ return s == "ok" || s == "failed"
+ }
+ var bs BotStatus
+ if json.Unmarshal(raw, &bs) == nil {
+ return bs.Online || bs.Good
+ }
+ return false
+}
+
type oneBotSender struct {
UserID json.RawMessage `json:"user_id"`
Nickname string `json:"nickname"`
Card string `json:"card"`
}
-type oneBotEvent struct {
- PostType string
- MessageType string
- SubType string
- MessageID string
- UserID int64
- GroupID int64
- Content string
- RawContent string
- IsBotMentioned bool
- Sender oneBotSender
- SelfID int64
- Time int64
- MetaEventType string
-}
-
type oneBotAPIRequest struct {
Action string `json:"action"`
Params interface{} `json:"params"`
Echo string `json:"echo,omitempty"`
}
-type oneBotSendPrivateMsgParams struct {
- UserID int64 `json:"user_id"`
- Message string `json:"message"`
-}
-
-type oneBotSendGroupMsgParams struct {
- GroupID int64 `json:"group_id"`
- Message string `json:"message"`
+type oneBotMessageSegment struct {
+ Type string `json:"type"`
+ Data map[string]interface{} `json:"data"`
}
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
@@ -101,9 +107,30 @@ func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*One
dedup: make(map[string]struct{}, dedupSize),
dedupRing: make([]string, dedupSize),
dedupIdx: 0,
+ pending: make(map[string]chan json.RawMessage),
}, nil
}
+func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
+ c.transcriber = transcriber
+}
+
+func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) {
+ go func() {
+ _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]interface{}{
+ "message_id": messageID,
+ "emoji_id": emojiID,
+ "set": set,
+ }, 5*time.Second)
+ if err != nil {
+ logger.DebugCF("onebot", "Failed to set emoji like", map[string]interface{}{
+ "message_id": messageID,
+ "error": err.Error(),
+ })
+ }
+ }()
+}
+
func (c *OneBotChannel) Start(ctx context.Context) error {
if c.config.WSUrl == "" {
return fmt.Errorf("OneBot ws_url not configured")
@@ -121,12 +148,12 @@ func (c *OneBotChannel) Start(ctx context.Context) error {
})
} else {
go c.listen()
+ c.fetchSelfID()
}
if c.config.ReconnectInterval > 0 {
go c.reconnectLoop()
} else {
- // If reconnect is disabled but initial connection failed, we cannot recover
if c.conn == nil {
return fmt.Errorf("failed to connect to OneBot and reconnect is disabled")
}
@@ -152,14 +179,141 @@ func (c *OneBotChannel) connect() error {
return err
}
+ conn.SetPongHandler(func(appData string) error {
+ _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
+ return nil
+ })
+ _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
+
c.mu.Lock()
c.conn = conn
c.mu.Unlock()
+ go c.pinger(conn)
+
logger.InfoC("onebot", "WebSocket connected")
return nil
}
+func (c *OneBotChannel) pinger(conn *websocket.Conn) {
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-c.ctx.Done():
+ return
+ case <-ticker.C:
+ c.writeMu.Lock()
+ err := conn.WriteMessage(websocket.PingMessage, nil)
+ c.writeMu.Unlock()
+ if err != nil {
+ logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]interface{}{
+ "error": err.Error(),
+ })
+ return
+ }
+ }
+ }
+}
+
+func (c *OneBotChannel) fetchSelfID() {
+ resp, err := c.sendAPIRequest("get_login_info", nil, 5*time.Second)
+ if err != nil {
+ logger.WarnCF("onebot", "Failed to get_login_info", map[string]interface{}{
+ "error": err.Error(),
+ })
+ return
+ }
+
+ type loginInfo struct {
+ UserID json.RawMessage `json:"user_id"`
+ Nickname string `json:"nickname"`
+ }
+ for _, extract := range []func() (*loginInfo, error){
+ func() (*loginInfo, error) {
+ var w struct {
+ Data loginInfo `json:"data"`
+ }
+ err := json.Unmarshal(resp, &w)
+ return &w.Data, err
+ },
+ func() (*loginInfo, error) {
+ var f loginInfo
+ err := json.Unmarshal(resp, &f)
+ return &f, err
+ },
+ } {
+ info, err := extract()
+ if err != nil || len(info.UserID) == 0 {
+ continue
+ }
+ if uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 {
+ atomic.StoreInt64(&c.selfID, uid)
+ logger.InfoCF("onebot", "Bot self ID retrieved", map[string]interface{}{
+ "self_id": uid,
+ "nickname": info.Nickname,
+ })
+ return
+ }
+ }
+
+ logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]interface{}{
+ "response": string(resp),
+ })
+}
+
+func (c *OneBotChannel) sendAPIRequest(action string, params interface{}, timeout time.Duration) (json.RawMessage, error) {
+ c.mu.Lock()
+ conn := c.conn
+ c.mu.Unlock()
+
+ if conn == nil {
+ return nil, fmt.Errorf("WebSocket not connected")
+ }
+
+ echo := fmt.Sprintf("api_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&c.echoCounter, 1))
+
+ ch := make(chan json.RawMessage, 1)
+ c.pendingMu.Lock()
+ c.pending[echo] = ch
+ c.pendingMu.Unlock()
+
+ defer func() {
+ c.pendingMu.Lock()
+ delete(c.pending, echo)
+ c.pendingMu.Unlock()
+ }()
+
+ req := oneBotAPIRequest{
+ Action: action,
+ Params: params,
+ Echo: echo,
+ }
+
+ data, err := json.Marshal(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal API request: %w", err)
+ }
+
+ c.writeMu.Lock()
+ err = conn.WriteMessage(websocket.TextMessage, data)
+ c.writeMu.Unlock()
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to write API request: %w", err)
+ }
+
+ select {
+ case resp := <-ch:
+ return resp, nil
+ case <-time.After(timeout):
+ return nil, fmt.Errorf("API request %s timed out after %v", action, timeout)
+ case <-c.ctx.Done():
+ return nil, fmt.Errorf("context cancelled")
+ }
+}
+
func (c *OneBotChannel) reconnectLoop() {
interval := time.Duration(c.config.ReconnectInterval) * time.Second
if interval < 5*time.Second {
@@ -183,6 +337,7 @@ func (c *OneBotChannel) reconnectLoop() {
})
} else {
go c.listen()
+ c.fetchSelfID()
}
}
}
@@ -197,6 +352,13 @@ func (c *OneBotChannel) Stop(ctx context.Context) error {
c.cancel()
}
+ c.pendingMu.Lock()
+ for echo, ch := range c.pending {
+ close(ch)
+ delete(c.pending, echo)
+ }
+ c.pendingMu.Unlock()
+
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
@@ -225,10 +387,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return err
}
- c.writeMu.Lock()
- c.echoCounter++
- echo := fmt.Sprintf("send_%d", c.echoCounter)
- c.writeMu.Unlock()
+ echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1))
req := oneBotAPIRequest{
Action: action,
@@ -252,67 +411,78 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return err
}
+ if msgID, ok := c.pendingEmojiMsg.LoadAndDelete(msg.ChatID); ok {
+ if mid, ok := msgID.(string); ok && mid != "" {
+ c.setMsgEmojiLike(mid, 289, false)
+ }
+ }
+
return nil
}
+func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMessageSegment {
+ var segments []oneBotMessageSegment
+
+ if lastMsgID, ok := c.lastMessageID.Load(chatID); ok {
+ if msgID, ok := lastMsgID.(string); ok && msgID != "" {
+ segments = append(segments, oneBotMessageSegment{
+ Type: "reply",
+ Data: map[string]interface{}{"id": msgID},
+ })
+ }
+ }
+
+ segments = append(segments, oneBotMessageSegment{
+ Type: "text",
+ Data: map[string]interface{}{"text": content},
+ })
+
+ return segments
+}
+
func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) {
chatID := msg.ChatID
+ segments := c.buildMessageSegments(chatID, msg.Content)
- if len(chatID) > 6 && chatID[:6] == "group:" {
- groupID, err := strconv.ParseInt(chatID[6:], 10, 64)
- if err != nil {
- return "", nil, fmt.Errorf("invalid group ID in chatID: %s", chatID)
- }
- return "send_group_msg", oneBotSendGroupMsgParams{
- GroupID: groupID,
- Message: msg.Content,
- }, nil
+ var action, idKey string
+ var rawID string
+ if rest, ok := strings.CutPrefix(chatID, "group:"); ok {
+ action, idKey, rawID = "send_group_msg", "group_id", rest
+ } else if rest, ok := strings.CutPrefix(chatID, "private:"); ok {
+ action, idKey, rawID = "send_private_msg", "user_id", rest
+ } else {
+ action, idKey, rawID = "send_private_msg", "user_id", chatID
}
- if len(chatID) > 8 && chatID[:8] == "private:" {
- userID, err := strconv.ParseInt(chatID[8:], 10, 64)
- if err != nil {
- return "", nil, fmt.Errorf("invalid user ID in chatID: %s", chatID)
- }
- return "send_private_msg", oneBotSendPrivateMsgParams{
- UserID: userID,
- Message: msg.Content,
- }, nil
- }
-
- userID, err := strconv.ParseInt(chatID, 10, 64)
+ id, err := strconv.ParseInt(rawID, 10, 64)
if err != nil {
- return "", nil, fmt.Errorf("invalid chatID for OneBot: %s", chatID)
+ return "", nil, fmt.Errorf("invalid %s in chatID: %s", idKey, chatID)
}
-
- return "send_private_msg", oneBotSendPrivateMsgParams{
- UserID: userID,
- Message: msg.Content,
- }, nil
+ return action, map[string]interface{}{idKey: id, "message": segments}, nil
}
func (c *OneBotChannel) listen() {
+ c.mu.Lock()
+ conn := c.conn
+ c.mu.Unlock()
+
+ if conn == nil {
+ logger.WarnC("onebot", "WebSocket connection is nil, listener exiting")
+ return
+ }
+
for {
select {
case <-c.ctx.Done():
return
default:
- c.mu.Lock()
- conn := c.conn
- c.mu.Unlock()
-
- if conn == nil {
- logger.WarnC("onebot", "WebSocket connection is nil, listener exiting")
- return
- }
-
_, message, err := conn.ReadMessage()
if err != nil {
logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{
"error": err.Error(),
})
c.mu.Lock()
- if c.conn != nil {
+ if c.conn == conn {
c.conn.Close()
c.conn = nil
}
@@ -320,10 +490,7 @@ func (c *OneBotChannel) listen() {
return
}
- logger.DebugCF("onebot", "Raw WebSocket message received", map[string]interface{}{
- "length": len(message),
- "payload": string(message),
- })
+ _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
var raw oneBotRawEvent
if err := json.Unmarshal(message, &raw); err != nil {
@@ -334,20 +501,37 @@ func (c *OneBotChannel) listen() {
continue
}
- if raw.Echo != "" || raw.Status.Online || raw.Status.Good {
- logger.DebugCF("onebot", "Received API response, skipping", map[string]interface{}{
- "echo": raw.Echo,
- "status": raw.Status,
- })
+ logger.DebugCF("onebot", "WebSocket event", map[string]interface{}{
+ "length": len(message),
+ "post_type": raw.PostType,
+ "sub_type": raw.SubType,
+ })
+
+ if raw.Echo != "" {
+ c.pendingMu.Lock()
+ ch, ok := c.pending[raw.Echo]
+ c.pendingMu.Unlock()
+
+ if ok {
+ select {
+ case ch <- message:
+ default:
+ }
+ } else {
+ logger.DebugCF("onebot", "Received API response (no waiter)", map[string]interface{}{
+ "echo": raw.Echo,
+ "status": string(raw.Status),
+ })
+ }
continue
}
- logger.DebugCF("onebot", "Parsed raw event", map[string]interface{}{
- "post_type": raw.PostType,
- "message_type": raw.MessageType,
- "sub_type": raw.SubType,
- "meta_event_type": raw.MetaEventType,
- })
+ if isAPIResponse(raw.Status) {
+ logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]interface{}{
+ "status": string(raw.Status),
+ })
+ continue
+ }
c.handleRawEvent(&raw)
}
@@ -386,9 +570,12 @@ func parseJSONString(raw json.RawMessage) string {
type parseMessageResult struct {
Text string
IsBotMentioned bool
+ Media []string
+ LocalFiles []string
+ ReplyTo string
}
-func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult {
+func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) parseMessageResult {
if len(raw) == 0 {
return parseMessageResult{}
}
@@ -408,60 +595,155 @@ func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult
}
var segments []map[string]interface{}
- if err := json.Unmarshal(raw, &segments); err == nil {
- var text string
- mentioned := false
- selfIDStr := strconv.FormatInt(selfID, 10)
- for _, seg := range segments {
- segType, _ := seg["type"].(string)
- data, _ := seg["data"].(map[string]interface{})
- switch segType {
- case "text":
- if data != nil {
- if t, ok := data["text"].(string); ok {
- text += t
- }
+ if err := json.Unmarshal(raw, &segments); err != nil {
+ return parseMessageResult{}
+ }
+
+ var textParts []string
+ mentioned := false
+ selfIDStr := strconv.FormatInt(selfID, 10)
+ var media []string
+ var localFiles []string
+ var replyTo string
+
+ for _, seg := range segments {
+ segType, _ := seg["type"].(string)
+ data, _ := seg["data"].(map[string]interface{})
+
+ switch segType {
+ case "text":
+ if data != nil {
+ if t, ok := data["text"].(string); ok {
+ textParts = append(textParts, t)
}
- case "at":
- if data != nil && selfID > 0 {
- qqVal := fmt.Sprintf("%v", data["qq"])
- if qqVal == selfIDStr || qqVal == "all" {
- mentioned = true
+ }
+
+ case "at":
+ if data != nil && selfID > 0 {
+ qqVal := fmt.Sprintf("%v", data["qq"])
+ if qqVal == selfIDStr || qqVal == "all" {
+ mentioned = true
+ }
+ }
+
+ case "image", "video", "file":
+ if data != nil {
+ url, _ := data["url"].(string)
+ if url != "" {
+ defaults := map[string]string{"image": "image.jpg", "video": "video.mp4", "file": "file"}
+ filename := defaults[segType]
+ if f, ok := data["file"].(string); ok && f != "" {
+ filename = f
+ } else if n, ok := data["name"].(string); ok && n != "" {
+ filename = n
+ }
+ localPath := utils.DownloadFile(url, filename, utils.DownloadOptions{
+ LoggerPrefix: "onebot",
+ })
+ if localPath != "" {
+ media = append(media, localPath)
+ localFiles = append(localFiles, localPath)
+ textParts = append(textParts, fmt.Sprintf("[%s]", segType))
}
}
}
+
+ case "record":
+ if data != nil {
+ url, _ := data["url"].(string)
+ if url != "" {
+ localPath := utils.DownloadFile(url, "voice.amr", utils.DownloadOptions{
+ LoggerPrefix: "onebot",
+ })
+ if localPath != "" {
+ localFiles = append(localFiles, localPath)
+ if c.transcriber != nil && c.transcriber.IsAvailable() {
+ tctx, tcancel := context.WithTimeout(c.ctx, 30*time.Second)
+ result, err := c.transcriber.Transcribe(tctx, localPath)
+ tcancel()
+ if err != nil {
+ logger.WarnCF("onebot", "Voice transcription failed", map[string]interface{}{
+ "error": err.Error(),
+ })
+ textParts = append(textParts, "[voice (transcription failed)]")
+ media = append(media, localPath)
+ } else {
+ textParts = append(textParts, fmt.Sprintf("[voice transcription: %s]", result.Text))
+ }
+ } else {
+ textParts = append(textParts, "[voice]")
+ media = append(media, localPath)
+ }
+ }
+ }
+ }
+
+ case "reply":
+ if data != nil {
+ if id, ok := data["id"]; ok {
+ replyTo = fmt.Sprintf("%v", id)
+ }
+ }
+
+ case "face":
+ if data != nil {
+ faceID, _ := data["id"]
+ textParts = append(textParts, fmt.Sprintf("[face:%v]", faceID))
+ }
+
+ case "forward":
+ textParts = append(textParts, "[forward message]")
+
+ default:
+
}
- return parseMessageResult{Text: strings.TrimSpace(text), IsBotMentioned: mentioned}
}
- return parseMessageResult{}
+
+ return parseMessageResult{
+ Text: strings.TrimSpace(strings.Join(textParts, "")),
+ IsBotMentioned: mentioned,
+ Media: media,
+ LocalFiles: localFiles,
+ ReplyTo: replyTo,
+ }
}
func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
switch raw.PostType {
case "message":
- evt, err := c.normalizeMessageEvent(raw)
- if err != nil {
- logger.WarnCF("onebot", "Failed to normalize message event", map[string]interface{}{
- "error": err.Error(),
- })
- return
+ if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 {
+ if !c.IsAllowed(strconv.FormatInt(userID, 10)) {
+ logger.DebugCF("onebot", "Message rejected by allowlist", map[string]interface{}{
+ "user_id": userID,
+ })
+ return
+ }
}
- c.handleMessage(evt)
+ c.handleMessage(raw)
+
+ case "message_sent":
+ logger.DebugCF("onebot", "Bot sent message event", map[string]interface{}{
+ "message_type": raw.MessageType,
+ "message_id": parseJSONString(raw.MessageID),
+ })
+
case "meta_event":
c.handleMetaEvent(raw)
+
case "notice":
- logger.DebugCF("onebot", "Notice event received", map[string]interface{}{
- "sub_type": raw.SubType,
- })
+ c.handleNoticeEvent(raw)
+
case "request":
logger.DebugCF("onebot", "Request event received", map[string]interface{}{
"sub_type": raw.SubType,
})
+
case "":
logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{
"echo": raw.Echo,
"status": raw.Status,
})
+
default:
logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{
"post_type": raw.PostType,
@@ -469,18 +751,51 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
}
}
-func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent, error) {
+func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
+ if raw.MetaEventType == "lifecycle" {
+ logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{"sub_type": raw.SubType})
+ } else if raw.MetaEventType != "heartbeat" {
+ logger.DebugCF("onebot", "Meta event: "+raw.MetaEventType, nil)
+ }
+}
+
+func (c *OneBotChannel) handleNoticeEvent(raw *oneBotRawEvent) {
+ fields := map[string]interface{}{
+ "notice_type": raw.NoticeType,
+ "sub_type": raw.SubType,
+ "group_id": parseJSONString(raw.GroupID),
+ "user_id": parseJSONString(raw.UserID),
+ "message_id": parseJSONString(raw.MessageID),
+ }
+ switch raw.NoticeType {
+ case "group_recall", "group_increase", "group_decrease",
+ "friend_add", "group_admin", "group_ban":
+ logger.InfoCF("onebot", "Notice: "+raw.NoticeType, fields)
+ default:
+ logger.DebugCF("onebot", "Notice: "+raw.NoticeType, fields)
+ }
+}
+
+func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
+ // Parse fields from raw event
userID, err := parseJSONInt64(raw.UserID)
if err != nil {
- return nil, fmt.Errorf("parse user_id: %w (raw: %s)", err, string(raw.UserID))
+ logger.WarnCF("onebot", "Failed to parse user_id", map[string]interface{}{
+ "error": err.Error(),
+ "raw": string(raw.UserID),
+ })
+ return
}
groupID, _ := parseJSONInt64(raw.GroupID)
selfID, _ := parseJSONInt64(raw.SelfID)
- ts, _ := parseJSONInt64(raw.Time)
messageID := parseJSONString(raw.MessageID)
- parsed := parseMessageContentEx(raw.Message, selfID)
+ if selfID == 0 {
+ selfID = atomic.LoadInt64(&c.selfID)
+ }
+
+ parsed := c.parseMessageSegments(raw.Message, selfID)
isBotMentioned := parsed.IsBotMentioned
content := raw.RawMessage
@@ -495,6 +810,10 @@ func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent
}
}
+ if parsed.Text != "" && content != parsed.Text && (len(parsed.Media) > 0 || parsed.ReplyTo != "") {
+ content = parsed.Text
+ }
+
var sender oneBotSender
if len(raw.Sender) > 0 {
if err := json.Unmarshal(raw.Sender, &sender); err != nil {
@@ -505,137 +824,111 @@ func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent
}
}
- logger.DebugCF("onebot", "Normalized message event", map[string]interface{}{
- "message_type": raw.MessageType,
- "user_id": userID,
- "group_id": groupID,
- "message_id": messageID,
- "content_len": len(content),
- "nickname": sender.Nickname,
- })
-
- return &oneBotEvent{
- PostType: raw.PostType,
- MessageType: raw.MessageType,
- SubType: raw.SubType,
- MessageID: messageID,
- UserID: userID,
- GroupID: groupID,
- Content: content,
- RawContent: raw.RawMessage,
- IsBotMentioned: isBotMentioned,
- Sender: sender,
- SelfID: selfID,
- Time: ts,
- MetaEventType: raw.MetaEventType,
- }, nil
-}
-
-func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
- switch raw.MetaEventType {
- case "lifecycle":
- logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{
- "sub_type": raw.SubType,
- })
- case "heartbeat":
- logger.DebugC("onebot", "Heartbeat received")
- default:
- logger.DebugCF("onebot", "Unknown meta_event_type", map[string]interface{}{
- "meta_event_type": raw.MetaEventType,
- })
+ // Clean up temp files when done
+ if len(parsed.LocalFiles) > 0 {
+ defer func() {
+ for _, f := range parsed.LocalFiles {
+ if err := os.Remove(f); err != nil {
+ logger.DebugCF("onebot", "Failed to remove temp file", map[string]interface{}{
+ "path": f,
+ "error": err.Error(),
+ })
+ }
+ }
+ }()
}
-}
-func (c *OneBotChannel) handleMessage(evt *oneBotEvent) {
- if c.isDuplicate(evt.MessageID) {
+ if c.isDuplicate(messageID) {
logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{
- "message_id": evt.MessageID,
+ "message_id": messageID,
})
return
}
- content := evt.Content
if content == "" {
logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{
- "message_id": evt.MessageID,
+ "message_id": messageID,
})
return
}
- senderID := strconv.FormatInt(evt.UserID, 10)
+ senderID := strconv.FormatInt(userID, 10)
var chatID string
metadata := map[string]string{
- "message_id": evt.MessageID,
+ "message_id": messageID,
}
- switch evt.MessageType {
+ if parsed.ReplyTo != "" {
+ metadata["reply_to_message_id"] = parsed.ReplyTo
+ }
+
+ switch raw.MessageType {
case "private":
chatID = "private:" + senderID
- logger.InfoCF("onebot", "Received private message", map[string]interface{}{
- "sender": senderID,
- "message_id": evt.MessageID,
- "length": len(content),
- "content": truncate(content, 100),
- })
+ metadata["peer_kind"] = "direct"
+ metadata["peer_id"] = senderID
case "group":
- groupIDStr := strconv.FormatInt(evt.GroupID, 10)
+ groupIDStr := strconv.FormatInt(groupID, 10)
chatID = "group:" + groupIDStr
+ metadata["peer_kind"] = "group"
+ metadata["peer_id"] = groupIDStr
metadata["group_id"] = groupIDStr
- senderUserID, _ := parseJSONInt64(evt.Sender.UserID)
+ senderUserID, _ := parseJSONInt64(sender.UserID)
if senderUserID > 0 {
metadata["sender_user_id"] = strconv.FormatInt(senderUserID, 10)
}
- if evt.Sender.Card != "" {
- metadata["sender_name"] = evt.Sender.Card
- } else if evt.Sender.Nickname != "" {
- metadata["sender_name"] = evt.Sender.Nickname
+ if sender.Card != "" {
+ metadata["sender_name"] = sender.Card
+ } else if sender.Nickname != "" {
+ metadata["sender_name"] = sender.Nickname
}
- triggered, strippedContent := c.checkGroupTrigger(content, evt.IsBotMentioned)
+ triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned)
if !triggered {
logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{
"sender": senderID,
"group": groupIDStr,
- "is_mentioned": evt.IsBotMentioned,
+ "is_mentioned": isBotMentioned,
"content": truncate(content, 100),
})
return
}
content = strippedContent
- logger.InfoCF("onebot", "Received group message", map[string]interface{}{
- "sender": senderID,
- "group": groupIDStr,
- "message_id": evt.MessageID,
- "is_mentioned": evt.IsBotMentioned,
- "length": len(content),
- "content": truncate(content, 100),
- })
-
default:
logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{
- "type": evt.MessageType,
- "message_id": evt.MessageID,
- "user_id": evt.UserID,
+ "type": raw.MessageType,
+ "message_id": messageID,
+ "user_id": userID,
})
return
}
- if evt.Sender.Nickname != "" {
- metadata["nickname"] = evt.Sender.Nickname
- }
-
- logger.DebugCF("onebot", "Forwarding message to bus", map[string]interface{}{
- "sender_id": senderID,
- "chat_id": chatID,
- "content": truncate(content, 100),
+ logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]interface{}{
+ "sender": senderID,
+ "chat_id": chatID,
+ "message_id": messageID,
+ "length": len(content),
+ "content": truncate(content, 100),
+ "media_count": len(parsed.Media),
})
- c.HandleMessage(senderID, chatID, content, []string{}, metadata)
+ if sender.Nickname != "" {
+ metadata["nickname"] = sender.Nickname
+ }
+
+ c.lastMessageID.Store(chatID, messageID)
+
+ if raw.MessageType == "group" && messageID != "" && messageID != "0" {
+ c.setMsgEmojiLike(messageID, 289, true)
+ c.pendingEmojiMsg.Store(chatID, messageID)
+ }
+
+ c.HandleMessage(senderID, chatID, content, parsed.Media, metadata)
}
func (c *OneBotChannel) isDuplicate(messageID string) bool {
diff --git a/pkg/channels/qq.go b/pkg/channels/qq.go
index 18b4ca0e0..79907df83 100644
--- a/pkg/channels/qq.go
+++ b/pkg/channels/qq.go
@@ -165,6 +165,8 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
// 转发到消息总线
metadata := map[string]string{
"message_id": data.ID,
+ "peer_kind": "direct",
+ "peer_id": senderID,
}
c.HandleMessage(senderID, senderID, content, []string{}, metadata)
@@ -207,6 +209,8 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
metadata := map[string]string{
"message_id": data.ID,
"group_id": data.GroupID,
+ "peer_kind": "group",
+ "peer_id": data.GroupID,
}
c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata)
diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go
index d86d08a9d..0060972ed 100644
--- a/pkg/channels/slack.go
+++ b/pkg/channels/slack.go
@@ -25,6 +25,7 @@ type SlackChannel struct {
api *slack.Client
socketClient *socketmode.Client
botUserID string
+ teamID string
transcriber *voice.GroqTranscriber
ctx context.Context
cancel context.CancelFunc
@@ -72,6 +73,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
return fmt.Errorf("slack auth test failed: %w", err)
}
c.botUserID = authResp.UserID
+ c.teamID = authResp.TeamID
logger.InfoCF("slack", "Slack bot connected", map[string]interface{}{
"bot_user_id": c.botUserID,
@@ -274,11 +276,21 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
return
}
+ peerKind := "channel"
+ peerID := channelID
+ if strings.HasPrefix(channelID, "D") {
+ peerKind = "direct"
+ peerID = senderID
+ }
+
metadata := map[string]string{
"message_ts": messageTS,
"channel_id": channelID,
"thread_ts": threadTS,
"platform": "slack",
+ "peer_kind": peerKind,
+ "peer_id": peerID,
+ "team_id": c.teamID,
}
logger.DebugCF("slack", "Received message", map[string]interface{}{
@@ -296,6 +308,13 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
return
}
+ if !c.IsAllowed(ev.User) {
+ logger.DebugCF("slack", "Mention rejected by allowlist", map[string]interface{}{
+ "user_id": ev.User,
+ })
+ return
+ }
+
senderID := ev.User
channelID := ev.Channel
threadTS := ev.ThreadTimeStamp
@@ -324,12 +343,22 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
return
}
+ mentionPeerKind := "channel"
+ mentionPeerID := channelID
+ if strings.HasPrefix(channelID, "D") {
+ mentionPeerKind = "direct"
+ mentionPeerID = senderID
+ }
+
metadata := map[string]string{
"message_ts": messageTS,
"channel_id": channelID,
"thread_ts": threadTS,
"platform": "slack",
"is_mention": "true",
+ "peer_kind": mentionPeerKind,
+ "peer_id": mentionPeerID,
+ "team_id": c.teamID,
}
c.HandleMessage(senderID, chatID, content, nil, metadata)
@@ -345,6 +374,13 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
c.socketClient.Ack(*event.Request)
}
+ if !c.IsAllowed(cmd.UserID) {
+ logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]interface{}{
+ "user_id": cmd.UserID,
+ })
+ return
+ }
+
senderID := cmd.UserID
channelID := cmd.ChannelID
chatID := channelID
@@ -359,6 +395,9 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
"platform": "slack",
"is_command": "true",
"trigger_id": cmd.TriggerID,
+ "peer_kind": "channel",
+ "peer_id": channelID,
+ "team_id": c.teamID,
}
logger.DebugCF("slack", "Slash command received", map[string]interface{}{
diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go
index b14b1632e..20bbf6830 100644
--- a/pkg/channels/telegram.go
+++ b/pkg/channels/telegram.go
@@ -11,7 +11,10 @@ import (
"sync"
"time"
+ th "github.com/mymmrac/telego/telegohandler"
+
"github.com/mymmrac/telego"
+ "github.com/mymmrac/telego/telegohandler"
tu "github.com/mymmrac/telego/telegoutil"
"github.com/sipeed/picoclaw/pkg/bus"
@@ -24,7 +27,8 @@ import (
type TelegramChannel struct {
*BaseChannel
bot *telego.Bot
- config config.TelegramConfig
+ commands TelegramCommander
+ config *config.Config
chatIDs map[string]int64
transcriber *voice.GroqTranscriber
placeholders sync.Map // chatID -> messageID
@@ -41,30 +45,39 @@ func (c *thinkingCancel) Cancel() {
}
}
-func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) {
+func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
var opts []telego.BotOption
+ telegramCfg := cfg.Channels.Telegram
- if cfg.Proxy != "" {
- proxyURL, parseErr := url.Parse(cfg.Proxy)
+ if telegramCfg.Proxy != "" {
+ proxyURL, parseErr := url.Parse(telegramCfg.Proxy)
if parseErr != nil {
- return nil, fmt.Errorf("invalid proxy URL %q: %w", cfg.Proxy, parseErr)
+ return nil, fmt.Errorf("invalid proxy URL %q: %w", telegramCfg.Proxy, parseErr)
}
opts = append(opts, telego.WithHTTPClient(&http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
},
}))
+ } else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" {
+ // Use environment proxy if configured
+ opts = append(opts, telego.WithHTTPClient(&http.Client{
+ Transport: &http.Transport{
+ Proxy: http.ProxyFromEnvironment,
+ },
+ }))
}
- bot, err := telego.NewBot(cfg.Token, opts...)
+ bot, err := telego.NewBot(telegramCfg.Token, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
}
- base := NewBaseChannel("telegram", cfg, bus, cfg.AllowFrom)
+ base := NewBaseChannel("telegram", telegramCfg, bus, telegramCfg.AllowFrom)
return &TelegramChannel{
BaseChannel: base,
+ commands: NewTelegramCommands(bot, cfg),
bot: bot,
config: cfg,
chatIDs: make(map[string]int64),
@@ -88,31 +101,45 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
return fmt.Errorf("failed to start long polling: %w", err)
}
+ bh, err := telegohandler.NewBotHandler(c.bot, updates)
+ if err != nil {
+ return fmt.Errorf("failed to create bot handler: %w", err)
+ }
+
+ bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ c.commands.Help(ctx, message)
+ return nil
+ }, th.CommandEqual("help"))
+ bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ return c.commands.Start(ctx, message)
+ }, th.CommandEqual("start"))
+
+ bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ return c.commands.Show(ctx, message)
+ }, th.CommandEqual("show"))
+
+ bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ return c.commands.List(ctx, message)
+ }, th.CommandEqual("list"))
+
+ bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ return c.handleMessage(ctx, &message)
+ }, th.AnyMessage())
+
c.setRunning(true)
logger.InfoCF("telegram", "Telegram bot connected", map[string]interface{}{
"username": c.bot.Username(),
})
+ go bh.Start()
+
go func() {
- for {
- select {
- case <-ctx.Done():
- return
- case update, ok := <-updates:
- if !ok {
- logger.InfoC("telegram", "Updates channel closed, reconnecting...")
- return
- }
- if update.Message != nil {
- c.handleMessage(ctx, update)
- }
- }
- }
+ <-ctx.Done()
+ bh.Stop()
}()
return nil
}
-
func (c *TelegramChannel) Stop(ctx context.Context) error {
logger.InfoC("telegram", "Stopping Telegram bot...")
c.setRunning(false)
@@ -166,30 +193,27 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return nil
}
-func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Update) {
- message := update.Message
+func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
if message == nil {
- return
+ return fmt.Errorf("message is nil")
}
user := message.From
if user == nil {
- return
+ return fmt.Errorf("message sender (user) is nil")
}
- userID := fmt.Sprintf("%d", user.ID)
- senderID := userID
+ senderID := fmt.Sprintf("%d", user.ID)
if user.Username != "" {
- senderID = fmt.Sprintf("%s|%s", userID, user.Username)
+ senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
}
// 检查白名单,避免为被拒绝的用户下载附件
- if !c.IsAllowed(userID) && !c.IsAllowed(senderID) {
+ if !c.IsAllowed(senderID) {
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{
- "user_id": userID,
- "username": user.Username,
+ "user_id": senderID,
})
- return
+ return nil
}
chatID := message.Chat.ID
@@ -222,7 +246,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
content += message.Caption
}
- if message.Photo != nil && len(message.Photo) > 0 {
+ if len(message.Photo) > 0 {
photo := message.Photo[len(message.Photo)-1]
photoPath := c.downloadPhoto(ctx, photo.FileID)
if photoPath != "" {
@@ -231,7 +255,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
if content != "" {
content += "\n"
}
- content += fmt.Sprintf("[image: photo]")
+ content += "[image: photo]"
}
}
@@ -252,7 +276,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
"error": err.Error(),
"path": voicePath,
})
- transcribedText = fmt.Sprintf("[voice (transcription failed)]")
+ transcribedText = "[voice (transcription failed)]"
} else {
transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text)
logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{
@@ -260,7 +284,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
})
}
} else {
- transcribedText = fmt.Sprintf("[voice]")
+ transcribedText = "[voice]"
}
if content != "" {
@@ -278,7 +302,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
if content != "" {
content += "\n"
}
- content += fmt.Sprintf("[audio]")
+ content += "[audio]"
}
}
@@ -290,7 +314,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
if content != "" {
content += "\n"
}
- content += fmt.Sprintf("[file]")
+ content += "[file]"
}
}
@@ -330,15 +354,25 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
c.placeholders.Store(chatIDStr, pID)
}
+ peerKind := "direct"
+ peerID := fmt.Sprintf("%d", user.ID)
+ if message.Chat.Type != "private" {
+ peerKind = "group"
+ peerID = fmt.Sprintf("%d", chatID)
+ }
+
metadata := map[string]string{
"message_id": fmt.Sprintf("%d", message.MessageID),
"user_id": fmt.Sprintf("%d", user.ID),
"username": user.Username,
"first_name": user.FirstName,
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
+ "peer_kind": peerKind,
+ "peer_id": peerID,
}
- c.HandleMessage(senderID, fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
+ c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
+ return nil
}
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram_commands.go
new file mode 100644
index 000000000..df245e156
--- /dev/null
+++ b/pkg/channels/telegram_commands.go
@@ -0,0 +1,153 @@
+package channels
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/mymmrac/telego"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+type TelegramCommander interface {
+ Help(ctx context.Context, message telego.Message) error
+ Start(ctx context.Context, message telego.Message) error
+ Show(ctx context.Context, message telego.Message) error
+ List(ctx context.Context, message telego.Message) error
+}
+
+type cmd struct {
+ bot *telego.Bot
+ config *config.Config
+}
+
+func NewTelegramCommands(bot *telego.Bot, cfg *config.Config) TelegramCommander {
+ return &cmd{
+ bot: bot,
+ config: cfg,
+ }
+}
+
+func commandArgs(text string) string {
+ parts := strings.SplitN(text, " ", 2)
+ if len(parts) < 2 {
+ return ""
+ }
+ return strings.TrimSpace(parts[1])
+}
+func (c *cmd) Help(ctx context.Context, message telego.Message) error {
+ msg := `/start - Start the bot
+/help - Show this help message
+/show [model|channel] - Show current configuration
+/list [models|channels] - List available options
+ `
+ _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
+ ChatID: telego.ChatID{ID: message.Chat.ID},
+ Text: msg,
+ ReplyParameters: &telego.ReplyParameters{
+ MessageID: message.MessageID,
+ },
+ })
+ return err
+}
+
+func (c *cmd) Start(ctx context.Context, message telego.Message) error {
+ _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
+ ChatID: telego.ChatID{ID: message.Chat.ID},
+ Text: "Hello! I am PicoClaw 🦞",
+ ReplyParameters: &telego.ReplyParameters{
+ MessageID: message.MessageID,
+ },
+ })
+ return err
+}
+
+func (c *cmd) Show(ctx context.Context, message telego.Message) error {
+ args := commandArgs(message.Text)
+ if args == "" {
+ _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
+ ChatID: telego.ChatID{ID: message.Chat.ID},
+ Text: "Usage: /show [model|channel]",
+ ReplyParameters: &telego.ReplyParameters{
+ MessageID: message.MessageID,
+ },
+ })
+ return err
+ }
+
+ var response string
+ switch args {
+ case "model":
+ response = fmt.Sprintf("Current Model: %s (Provider: %s)",
+ c.config.Agents.Defaults.Model,
+ c.config.Agents.Defaults.Provider)
+ case "channel":
+ response = "Current Channel: telegram"
+ default:
+ response = fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args)
+ }
+
+ _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
+ ChatID: telego.ChatID{ID: message.Chat.ID},
+ Text: response,
+ ReplyParameters: &telego.ReplyParameters{
+ MessageID: message.MessageID,
+ },
+ })
+ return err
+}
+func (c *cmd) List(ctx context.Context, message telego.Message) error {
+ args := commandArgs(message.Text)
+ if args == "" {
+ _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
+ ChatID: telego.ChatID{ID: message.Chat.ID},
+ Text: "Usage: /list [models|channels]",
+ ReplyParameters: &telego.ReplyParameters{
+ MessageID: message.MessageID,
+ },
+ })
+ return err
+ }
+
+ var response string
+ switch args {
+ case "models":
+ provider := c.config.Agents.Defaults.Provider
+ if provider == "" {
+ provider = "configured default"
+ }
+ response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml",
+ c.config.Agents.Defaults.Model, provider)
+
+ case "channels":
+ var enabled []string
+ if c.config.Channels.Telegram.Enabled {
+ enabled = append(enabled, "telegram")
+ }
+ if c.config.Channels.WhatsApp.Enabled {
+ enabled = append(enabled, "whatsapp")
+ }
+ if c.config.Channels.Feishu.Enabled {
+ enabled = append(enabled, "feishu")
+ }
+ if c.config.Channels.Discord.Enabled {
+ enabled = append(enabled, "discord")
+ }
+ if c.config.Channels.Slack.Enabled {
+ enabled = append(enabled, "slack")
+ }
+ response = fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- "))
+
+ default:
+ response = fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args)
+ }
+
+ _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
+ ChatID: telego.ChatID{ID: message.Chat.ID},
+ Text: response,
+ ReplyParameters: &telego.ReplyParameters{
+ MessageID: message.MessageID,
+ },
+ })
+ return err
+}
diff --git a/pkg/channels/whatsapp.go b/pkg/channels/whatsapp.go
index c95e59578..065424e0c 100644
--- a/pkg/channels/whatsapp.go
+++ b/pkg/channels/whatsapp.go
@@ -178,6 +178,14 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
metadata["user_name"] = userName
}
+ if chatID == senderID {
+ metadata["peer_kind"] = "direct"
+ metadata["peer_id"] = senderID
+ } else {
+ metadata["peer_kind"] = "group"
+ metadata["peer_id"] = chatID
+ }
+
log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50))
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 8ad7e0337..d4cf5aa59 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -5,11 +5,14 @@ import (
"fmt"
"os"
"path/filepath"
- "sync"
+ "sync/atomic"
"github.com/caarlos0/env/v11"
)
+// rrCounter is a global counter for round-robin load balancing across models.
+var rrCounter atomic.Uint64
+
// FlexibleStringSlice is a []string that also accepts JSON numbers,
// so allow_from can contain both "123" and 123.
type FlexibleStringSlice []string
@@ -45,27 +48,135 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
type Config struct {
Agents AgentsConfig `json:"agents"`
+ Bindings []AgentBinding `json:"bindings,omitempty"`
+ Session SessionConfig `json:"session,omitempty"`
Channels ChannelsConfig `json:"channels"`
- Providers ProvidersConfig `json:"providers"`
+ Providers ProvidersConfig `json:"providers,omitempty"`
+ ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
Gateway GatewayConfig `json:"gateway"`
Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"`
- mu sync.RWMutex
+}
+
+// MarshalJSON implements custom JSON marshaling for Config
+// to omit providers section when empty and session when empty
+func (c Config) MarshalJSON() ([]byte, error) {
+ type Alias Config
+ aux := &struct {
+ Providers *ProvidersConfig `json:"providers,omitempty"`
+ Session *SessionConfig `json:"session,omitempty"`
+ *Alias
+ }{
+ Alias: (*Alias)(&c),
+ }
+
+ // Only include providers if not empty
+ if !c.Providers.IsEmpty() {
+ aux.Providers = &c.Providers
+ }
+
+ // Only include session if not empty
+ if c.Session.DMScope != "" || len(c.Session.IdentityLinks) > 0 {
+ aux.Session = &c.Session
+ }
+
+ return json.Marshal(aux)
}
type AgentsConfig struct {
Defaults AgentDefaults `json:"defaults"`
+ List []AgentConfig `json:"list,omitempty"`
+}
+
+// AgentModelConfig supports both string and structured model config.
+// String format: "gpt-4" (just primary, no fallbacks)
+// Object format: {"primary": "gpt-4", "fallbacks": ["claude-haiku"]}
+type AgentModelConfig struct {
+ Primary string `json:"primary,omitempty"`
+ Fallbacks []string `json:"fallbacks,omitempty"`
+}
+
+func (m *AgentModelConfig) UnmarshalJSON(data []byte) error {
+ var s string
+ if err := json.Unmarshal(data, &s); err == nil {
+ m.Primary = s
+ m.Fallbacks = nil
+ return nil
+ }
+ type raw struct {
+ Primary string `json:"primary"`
+ Fallbacks []string `json:"fallbacks"`
+ }
+ var r raw
+ if err := json.Unmarshal(data, &r); err != nil {
+ return err
+ }
+ m.Primary = r.Primary
+ m.Fallbacks = r.Fallbacks
+ return nil
+}
+
+func (m AgentModelConfig) MarshalJSON() ([]byte, error) {
+ if len(m.Fallbacks) == 0 && m.Primary != "" {
+ return json.Marshal(m.Primary)
+ }
+ type raw struct {
+ Primary string `json:"primary,omitempty"`
+ Fallbacks []string `json:"fallbacks,omitempty"`
+ }
+ return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks})
+}
+
+type AgentConfig struct {
+ ID string `json:"id"`
+ Default bool `json:"default,omitempty"`
+ Name string `json:"name,omitempty"`
+ Workspace string `json:"workspace,omitempty"`
+ Model *AgentModelConfig `json:"model,omitempty"`
+ Skills []string `json:"skills,omitempty"`
+ Subagents *SubagentsConfig `json:"subagents,omitempty"`
+}
+
+type SubagentsConfig struct {
+ AllowAgents []string `json:"allow_agents,omitempty"`
+ Model *AgentModelConfig `json:"model,omitempty"`
+}
+
+type PeerMatch struct {
+ Kind string `json:"kind"`
+ ID string `json:"id"`
+}
+
+type BindingMatch struct {
+ Channel string `json:"channel"`
+ AccountID string `json:"account_id,omitempty"`
+ Peer *PeerMatch `json:"peer,omitempty"`
+ GuildID string `json:"guild_id,omitempty"`
+ TeamID string `json:"team_id,omitempty"`
+}
+
+type AgentBinding struct {
+ AgentID string `json:"agent_id"`
+ Match BindingMatch `json:"match"`
+}
+
+type SessionConfig struct {
+ DMScope string `json:"dm_scope,omitempty"`
+ IdentityLinks map[string][]string `json:"identity_links,omitempty"`
}
type AgentDefaults struct {
- Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
- RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
- Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
- Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
- MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
- Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
- MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
+ Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
+ RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
+ Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
+ Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
+ ModelFallbacks []string `json:"model_fallbacks,omitempty"`
+ ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
+ ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
+ MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
+ Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
+ MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
}
type ChannelsConfig struct {
@@ -167,18 +278,55 @@ type DevicesConfig struct {
}
type ProvidersConfig struct {
- Anthropic ProviderConfig `json:"anthropic"`
- OpenAI ProviderConfig `json:"openai"`
- OpenRouter ProviderConfig `json:"openrouter"`
- Groq ProviderConfig `json:"groq"`
- Zhipu ProviderConfig `json:"zhipu"`
- VLLM ProviderConfig `json:"vllm"`
- Gemini ProviderConfig `json:"gemini"`
- Nvidia ProviderConfig `json:"nvidia"`
- Moonshot ProviderConfig `json:"moonshot"`
- ShengSuanYun ProviderConfig `json:"shengsuanyun"`
- DeepSeek ProviderConfig `json:"deepseek"`
- GitHubCopilot ProviderConfig `json:"github_copilot"`
+ Anthropic ProviderConfig `json:"anthropic"`
+ OpenAI OpenAIProviderConfig `json:"openai"`
+ OpenRouter ProviderConfig `json:"openrouter"`
+ Groq ProviderConfig `json:"groq"`
+ Zhipu ProviderConfig `json:"zhipu"`
+ VLLM ProviderConfig `json:"vllm"`
+ Gemini ProviderConfig `json:"gemini"`
+ Nvidia ProviderConfig `json:"nvidia"`
+ Ollama ProviderConfig `json:"ollama"`
+ Moonshot ProviderConfig `json:"moonshot"`
+ ShengSuanYun ProviderConfig `json:"shengsuanyun"`
+ DeepSeek ProviderConfig `json:"deepseek"`
+ Cerebras ProviderConfig `json:"cerebras"`
+ VolcEngine ProviderConfig `json:"volcengine"`
+ GitHubCopilot ProviderConfig `json:"github_copilot"`
+ Antigravity ProviderConfig `json:"antigravity"`
+ Qwen ProviderConfig `json:"qwen"`
+}
+
+// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
+// Note: WebSearch is an optimization option and doesn't count as "non-empty"
+func (p ProvidersConfig) IsEmpty() bool {
+ return p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" &&
+ p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" &&
+ p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" &&
+ p.Groq.APIKey == "" && p.Groq.APIBase == "" &&
+ p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" &&
+ p.VLLM.APIKey == "" && p.VLLM.APIBase == "" &&
+ p.Gemini.APIKey == "" && p.Gemini.APIBase == "" &&
+ p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" &&
+ p.Ollama.APIKey == "" && p.Ollama.APIBase == "" &&
+ p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" &&
+ p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" &&
+ p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" &&
+ p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" &&
+ p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
+ p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
+ p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
+ p.Qwen.APIKey == "" && p.Qwen.APIBase == ""
+}
+
+// MarshalJSON implements custom JSON marshaling for ProvidersConfig
+// to omit the entire section when empty
+func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
+ if p.IsEmpty() {
+ return []byte("null"), nil
+ }
+ type Alias ProvidersConfig
+ return json.Marshal((*Alias)(&p))
}
type ProviderConfig struct {
@@ -189,6 +337,47 @@ type ProviderConfig struct {
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc`
}
+type OpenAIProviderConfig struct {
+ ProviderConfig
+ WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"`
+}
+
+// ModelConfig represents a model-centric provider configuration.
+// It allows adding new providers (especially OpenAI-compatible ones) via configuration only.
+// The model field uses protocol prefix format: [protocol/]model-identifier
+// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot
+// Default protocol is "openai" if no prefix is specified.
+type ModelConfig struct {
+ // Required fields
+ ModelName string `json:"model_name"` // User-facing alias for the model
+ Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6")
+
+ // HTTP-based providers
+ APIBase string `json:"api_base,omitempty"` // API endpoint URL
+ APIKey string `json:"api_key"` // API authentication key
+ Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
+
+ // Special providers (CLI-based, OAuth, etc.)
+ AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
+ ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc
+ Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
+
+ // Optional optimizations
+ RPM int `json:"rpm,omitempty"` // Requests per minute limit
+ MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
+}
+
+// Validate checks if the ModelConfig has all required fields.
+func (c *ModelConfig) Validate() error {
+ if c.ModelName == "" {
+ return fmt.Errorf("model_name is required")
+ }
+ if c.Model == "" {
+ return fmt.Errorf("model is required")
+ }
+ return nil
+}
+
type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
@@ -205,9 +394,25 @@ type DuckDuckGoConfig struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
}
+type PerplexityConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
+}
+
type WebToolsConfig struct {
Brave BraveConfig `json:"brave"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
+ Perplexity PerplexityConfig `json:"perplexity"`
+}
+
+type CronToolsConfig struct {
+ ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
+}
+
+type ExecConfig struct {
+ EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"`
+ CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"`
}
type ToolsConfig struct {
@@ -352,13 +557,20 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
+ // Auto-migrate: if only legacy providers config exists, convert to model_list
+ if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
+ cfg.ModelList = ConvertProvidersToModelList(cfg)
+ }
+
+ // Validate model_list for uniqueness and required fields
+ if err := cfg.ValidateModelList(); err != nil {
+ return nil, err
+ }
+
return cfg, nil
}
func SaveConfig(path string, cfg *Config) error {
- cfg.mu.RLock()
- defer cfg.mu.RUnlock()
-
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
@@ -369,18 +581,14 @@ func SaveConfig(path string, cfg *Config) error {
return err
}
- return os.WriteFile(path, data, 0644)
+ return os.WriteFile(path, data, 0600)
}
func (c *Config) WorkspacePath() string {
- c.mu.RLock()
- defer c.mu.RUnlock()
return expandHome(c.Agents.Defaults.Workspace)
}
func (c *Config) GetAPIKey() string {
- c.mu.RLock()
- defer c.mu.RUnlock()
if c.Providers.OpenRouter.APIKey != "" {
return c.Providers.OpenRouter.APIKey
}
@@ -405,12 +613,13 @@ func (c *Config) GetAPIKey() string {
if c.Providers.ShengSuanYun.APIKey != "" {
return c.Providers.ShengSuanYun.APIKey
}
+ if c.Providers.Cerebras.APIKey != "" {
+ return c.Providers.Cerebras.APIKey
+ }
return ""
}
func (c *Config) GetAPIBase() string {
- c.mu.RLock()
- defer c.mu.RUnlock()
if c.Providers.OpenRouter.APIKey != "" {
if c.Providers.OpenRouter.APIBase != "" {
return c.Providers.OpenRouter.APIBase
@@ -439,3 +648,65 @@ func expandHome(path string) string {
}
return path
}
+
+// GetModelConfig returns the ModelConfig for the given model name.
+// If multiple configs exist with the same model_name, it uses round-robin
+// selection for load balancing. Returns an error if the model is not found.
+func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
+ matches := c.findMatches(modelName)
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("model %q not found in model_list or providers", modelName)
+ }
+ if len(matches) == 1 {
+ return &matches[0], nil
+ }
+
+ // Multiple configs - use round-robin for load balancing
+ idx := rrCounter.Add(1) % uint64(len(matches))
+ return &matches[idx], nil
+}
+
+// findMatches finds all ModelConfig entries with the given model_name.
+func (c *Config) findMatches(modelName string) []ModelConfig {
+ var matches []ModelConfig
+ for i := range c.ModelList {
+ if c.ModelList[i].ModelName == modelName {
+ matches = append(matches, c.ModelList[i])
+ }
+ }
+ return matches
+}
+
+// HasProvidersConfig checks if any provider in the old providers config has configuration.
+func (c *Config) HasProvidersConfig() bool {
+ v := c.Providers
+ return v.Anthropic.APIKey != "" || v.Anthropic.APIBase != "" ||
+ v.OpenAI.APIKey != "" || v.OpenAI.APIBase != "" ||
+ v.OpenRouter.APIKey != "" || v.OpenRouter.APIBase != "" ||
+ v.Groq.APIKey != "" || v.Groq.APIBase != "" ||
+ v.Zhipu.APIKey != "" || v.Zhipu.APIBase != "" ||
+ v.VLLM.APIKey != "" || v.VLLM.APIBase != "" ||
+ v.Gemini.APIKey != "" || v.Gemini.APIBase != "" ||
+ v.Nvidia.APIKey != "" || v.Nvidia.APIBase != "" ||
+ v.Ollama.APIKey != "" || v.Ollama.APIBase != "" ||
+ v.Moonshot.APIKey != "" || v.Moonshot.APIBase != "" ||
+ v.ShengSuanYun.APIKey != "" || v.ShengSuanYun.APIBase != "" ||
+ v.DeepSeek.APIKey != "" || v.DeepSeek.APIBase != "" ||
+ v.Cerebras.APIKey != "" || v.Cerebras.APIBase != "" ||
+ v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" ||
+ v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" ||
+ v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" ||
+ v.Qwen.APIKey != "" || v.Qwen.APIBase != ""
+}
+
+// ValidateModelList validates all ModelConfig entries in the model_list.
+// It checks that each model config is valid.
+// Note: Multiple entries with the same model_name are allowed for load balancing.
+func (c *Config) ValidateModelList() error {
+ for i := range c.ModelList {
+ if err := c.ModelList[i].Validate(); err != nil {
+ return fmt.Errorf("model_list[%d]: %w", i, err)
+ }
+ }
+ return nil
+}
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 14618b109..7e706d8ce 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -1,9 +1,193 @@
package config
import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "runtime"
"testing"
)
+func TestAgentModelConfig_UnmarshalString(t *testing.T) {
+ var m AgentModelConfig
+ if err := json.Unmarshal([]byte(`"gpt-4"`), &m); err != nil {
+ t.Fatalf("unmarshal string: %v", err)
+ }
+ if m.Primary != "gpt-4" {
+ t.Errorf("Primary = %q, want 'gpt-4'", m.Primary)
+ }
+ if m.Fallbacks != nil {
+ t.Errorf("Fallbacks = %v, want nil", m.Fallbacks)
+ }
+}
+
+func TestAgentModelConfig_UnmarshalObject(t *testing.T) {
+ var m AgentModelConfig
+ data := `{"primary": "claude-opus", "fallbacks": ["gpt-4o-mini", "haiku"]}`
+ if err := json.Unmarshal([]byte(data), &m); err != nil {
+ t.Fatalf("unmarshal object: %v", err)
+ }
+ if m.Primary != "claude-opus" {
+ t.Errorf("Primary = %q, want 'claude-opus'", m.Primary)
+ }
+ if len(m.Fallbacks) != 2 {
+ t.Fatalf("Fallbacks len = %d, want 2", len(m.Fallbacks))
+ }
+ if m.Fallbacks[0] != "gpt-4o-mini" || m.Fallbacks[1] != "haiku" {
+ t.Errorf("Fallbacks = %v", m.Fallbacks)
+ }
+}
+
+func TestAgentModelConfig_MarshalString(t *testing.T) {
+ m := AgentModelConfig{Primary: "gpt-4"}
+ data, err := json.Marshal(m)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if string(data) != `"gpt-4"` {
+ t.Errorf("marshal = %s, want '\"gpt-4\"'", string(data))
+ }
+}
+
+func TestAgentModelConfig_MarshalObject(t *testing.T) {
+ m := AgentModelConfig{Primary: "claude-opus", Fallbacks: []string{"haiku"}}
+ data, err := json.Marshal(m)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ var result map[string]interface{}
+ json.Unmarshal(data, &result)
+ if result["primary"] != "claude-opus" {
+ t.Errorf("primary = %v", result["primary"])
+ }
+}
+
+func TestAgentConfig_FullParse(t *testing.T) {
+ jsonData := `{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "max_tool_iterations": 20
+ },
+ "list": [
+ {
+ "id": "sales",
+ "default": true,
+ "name": "Sales Bot",
+ "model": "gpt-4"
+ },
+ {
+ "id": "support",
+ "name": "Support Bot",
+ "model": {
+ "primary": "claude-opus",
+ "fallbacks": ["haiku"]
+ },
+ "subagents": {
+ "allow_agents": ["sales"]
+ }
+ }
+ ]
+ },
+ "bindings": [
+ {
+ "agent_id": "support",
+ "match": {
+ "channel": "telegram",
+ "account_id": "*",
+ "peer": {"kind": "direct", "id": "user123"}
+ }
+ }
+ ],
+ "session": {
+ "dm_scope": "per-peer",
+ "identity_links": {
+ "john": ["telegram:123", "discord:john#1234"]
+ }
+ }
+ }`
+
+ cfg := DefaultConfig()
+ if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ if len(cfg.Agents.List) != 2 {
+ t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List))
+ }
+
+ sales := cfg.Agents.List[0]
+ if sales.ID != "sales" || !sales.Default || sales.Name != "Sales Bot" {
+ t.Errorf("sales = %+v", sales)
+ }
+ if sales.Model == nil || sales.Model.Primary != "gpt-4" {
+ t.Errorf("sales.Model = %+v", sales.Model)
+ }
+
+ support := cfg.Agents.List[1]
+ if support.ID != "support" || support.Name != "Support Bot" {
+ t.Errorf("support = %+v", support)
+ }
+ if support.Model == nil || support.Model.Primary != "claude-opus" {
+ t.Errorf("support.Model = %+v", support.Model)
+ }
+ if len(support.Model.Fallbacks) != 1 || support.Model.Fallbacks[0] != "haiku" {
+ t.Errorf("support.Model.Fallbacks = %v", support.Model.Fallbacks)
+ }
+ if support.Subagents == nil || len(support.Subagents.AllowAgents) != 1 {
+ t.Errorf("support.Subagents = %+v", support.Subagents)
+ }
+
+ if len(cfg.Bindings) != 1 {
+ t.Fatalf("bindings len = %d, want 1", len(cfg.Bindings))
+ }
+ binding := cfg.Bindings[0]
+ if binding.AgentID != "support" || binding.Match.Channel != "telegram" {
+ t.Errorf("binding = %+v", binding)
+ }
+ if binding.Match.Peer == nil || binding.Match.Peer.Kind != "direct" || binding.Match.Peer.ID != "user123" {
+ t.Errorf("binding.Match.Peer = %+v", binding.Match.Peer)
+ }
+
+ if cfg.Session.DMScope != "per-peer" {
+ t.Errorf("Session.DMScope = %q", cfg.Session.DMScope)
+ }
+ if len(cfg.Session.IdentityLinks) != 1 {
+ t.Errorf("Session.IdentityLinks = %v", cfg.Session.IdentityLinks)
+ }
+ links := cfg.Session.IdentityLinks["john"]
+ if len(links) != 2 {
+ t.Errorf("john links = %v", links)
+ }
+}
+
+func TestConfig_BackwardCompat_NoAgentsList(t *testing.T) {
+ jsonData := `{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "max_tool_iterations": 20
+ }
+ }
+ }`
+
+ cfg := DefaultConfig()
+ if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ if len(cfg.Agents.List) != 0 {
+ t.Errorf("agents.list should be empty for backward compat, got %d", len(cfg.Agents.List))
+ }
+ if len(cfg.Bindings) != 0 {
+ t.Errorf("bindings should be empty, got %d", len(cfg.Bindings))
+ }
+}
+
// TestDefaultConfig_HeartbeatEnabled verifies heartbeat is enabled by default
func TestDefaultConfig_HeartbeatEnabled(t *testing.T) {
cfg := DefaultConfig()
@@ -17,8 +201,6 @@ func TestDefaultConfig_HeartbeatEnabled(t *testing.T) {
func TestDefaultConfig_WorkspacePath(t *testing.T) {
cfg := DefaultConfig()
- // Just verify the workspace is set, don't compare exact paths
- // since expandHome behavior may differ based on environment
if cfg.Agents.Defaults.Workspace == "" {
t.Error("Workspace should not be empty")
}
@@ -55,8 +237,8 @@ func TestDefaultConfig_MaxToolIterations(t *testing.T) {
func TestDefaultConfig_Temperature(t *testing.T) {
cfg := DefaultConfig()
- if cfg.Agents.Defaults.Temperature == 0 {
- t.Error("Temperature should not be zero")
+ if cfg.Agents.Defaults.Temperature != nil {
+ t.Error("Temperature should be nil when not provided")
}
}
@@ -76,7 +258,6 @@ func TestDefaultConfig_Gateway(t *testing.T) {
func TestDefaultConfig_Providers(t *testing.T) {
cfg := DefaultConfig()
- // Verify all providers are empty by default
if cfg.Providers.Anthropic.APIKey != "" {
t.Error("Anthropic API key should be empty by default")
}
@@ -86,46 +267,18 @@ func TestDefaultConfig_Providers(t *testing.T) {
if cfg.Providers.OpenRouter.APIKey != "" {
t.Error("OpenRouter API key should be empty by default")
}
- if cfg.Providers.Groq.APIKey != "" {
- t.Error("Groq API key should be empty by default")
- }
- if cfg.Providers.Zhipu.APIKey != "" {
- t.Error("Zhipu API key should be empty by default")
- }
- if cfg.Providers.VLLM.APIKey != "" {
- t.Error("VLLM API key should be empty by default")
- }
- if cfg.Providers.Gemini.APIKey != "" {
- t.Error("Gemini API key should be empty by default")
- }
}
// TestDefaultConfig_Channels verifies channels are disabled by default
func TestDefaultConfig_Channels(t *testing.T) {
cfg := DefaultConfig()
- // Verify all channels are disabled by default
- if cfg.Channels.WhatsApp.Enabled {
- t.Error("WhatsApp should be disabled by default")
- }
if cfg.Channels.Telegram.Enabled {
t.Error("Telegram should be disabled by default")
}
- if cfg.Channels.Feishu.Enabled {
- t.Error("Feishu should be disabled by default")
- }
if cfg.Channels.Discord.Enabled {
t.Error("Discord should be disabled by default")
}
- if cfg.Channels.MaixCam.Enabled {
- t.Error("MaixCam should be disabled by default")
- }
- if cfg.Channels.QQ.Enabled {
- t.Error("QQ should be disabled by default")
- }
- if cfg.Channels.DingTalk.Enabled {
- t.Error("DingTalk should be disabled by default")
- }
if cfg.Channels.Slack.Enabled {
t.Error("Slack should be disabled by default")
}
@@ -147,19 +300,42 @@ func TestDefaultConfig_WebTools(t *testing.T) {
}
}
+func TestSaveConfig_FilePermissions(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("file permission bits are not enforced on Windows")
+ }
+
+ tmpDir := t.TempDir()
+ path := filepath.Join(tmpDir, "config.json")
+
+ cfg := DefaultConfig()
+ if err := SaveConfig(path, cfg); err != nil {
+ t.Fatalf("SaveConfig failed: %v", err)
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("Stat failed: %v", err)
+ }
+
+ perm := info.Mode().Perm()
+ if perm != 0600 {
+ t.Errorf("config file has permission %04o, want 0600", perm)
+ }
+}
+
// TestConfig_Complete verifies all config fields are set
func TestConfig_Complete(t *testing.T) {
cfg := DefaultConfig()
- // Verify complete config structure
if cfg.Agents.Defaults.Workspace == "" {
t.Error("Workspace should not be empty")
}
if cfg.Agents.Defaults.Model == "" {
t.Error("Model should not be empty")
}
- if cfg.Agents.Defaults.Temperature == 0 {
- t.Error("Temperature should have default value")
+ if cfg.Agents.Defaults.Temperature != nil {
+ t.Error("Temperature should be nil when not provided")
}
if cfg.Agents.Defaults.MaxTokens == 0 {
t.Error("MaxTokens should not be zero")
@@ -177,3 +353,42 @@ func TestConfig_Complete(t *testing.T) {
t.Error("Heartbeat should be enabled by default")
}
}
+
+func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) {
+ cfg := DefaultConfig()
+ if !cfg.Providers.OpenAI.WebSearch {
+ t.Fatal("DefaultConfig().Providers.OpenAI.WebSearch should be true")
+ }
+}
+
+func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"api_base":""}}}`), 0o600); err != nil {
+ t.Fatalf("WriteFile() error: %v", err)
+ }
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error: %v", err)
+ }
+ if !cfg.Providers.OpenAI.WebSearch {
+ t.Fatal("OpenAI codex web search should remain true when unset in config file")
+ }
+}
+
+func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"web_search":false}}}`), 0o600); err != nil {
+ t.Fatalf("WriteFile() error: %v", err)
+ }
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error: %v", err)
+ }
+ if cfg.Providers.OpenAI.WebSearch {
+ t.Fatal("OpenAI codex web search should be false when disabled in config file")
+ }
+}
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
new file mode 100644
index 000000000..70ba67adf
--- /dev/null
+++ b/pkg/config/defaults.go
@@ -0,0 +1,275 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package config
+
+// DefaultConfig returns the default configuration for PicoClaw.
+func DefaultConfig() *Config {
+ return &Config{
+ Agents: AgentsConfig{
+ Defaults: AgentDefaults{
+ Workspace: "~/.picoclaw/workspace",
+ RestrictToWorkspace: true,
+ Provider: "",
+ Model: "glm-4.7",
+ MaxTokens: 8192,
+ Temperature: nil, // nil means use provider default
+ MaxToolIterations: 20,
+ },
+ },
+ Bindings: []AgentBinding{},
+ Session: SessionConfig{
+ DMScope: "main",
+ },
+ Channels: ChannelsConfig{
+ WhatsApp: WhatsAppConfig{
+ Enabled: false,
+ BridgeURL: "ws://localhost:3001",
+ AllowFrom: FlexibleStringSlice{},
+ },
+ Telegram: TelegramConfig{
+ Enabled: false,
+ Token: "",
+ AllowFrom: FlexibleStringSlice{},
+ },
+ Feishu: FeishuConfig{
+ Enabled: false,
+ AppID: "",
+ AppSecret: "",
+ EncryptKey: "",
+ VerificationToken: "",
+ AllowFrom: FlexibleStringSlice{},
+ },
+ Discord: DiscordConfig{
+ Enabled: false,
+ Token: "",
+ AllowFrom: FlexibleStringSlice{},
+ },
+ MaixCam: MaixCamConfig{
+ Enabled: false,
+ Host: "0.0.0.0",
+ Port: 18790,
+ AllowFrom: FlexibleStringSlice{},
+ },
+ QQ: QQConfig{
+ Enabled: false,
+ AppID: "",
+ AppSecret: "",
+ AllowFrom: FlexibleStringSlice{},
+ },
+ DingTalk: DingTalkConfig{
+ Enabled: false,
+ ClientID: "",
+ ClientSecret: "",
+ AllowFrom: FlexibleStringSlice{},
+ },
+ Slack: SlackConfig{
+ Enabled: false,
+ BotToken: "",
+ AppToken: "",
+ AllowFrom: FlexibleStringSlice{},
+ },
+ LINE: LINEConfig{
+ Enabled: false,
+ ChannelSecret: "",
+ ChannelAccessToken: "",
+ WebhookHost: "0.0.0.0",
+ WebhookPort: 18791,
+ WebhookPath: "/webhook/line",
+ AllowFrom: FlexibleStringSlice{},
+ },
+ OneBot: OneBotConfig{
+ Enabled: false,
+ WSUrl: "ws://127.0.0.1:3001",
+ AccessToken: "",
+ ReconnectInterval: 5,
+ GroupTriggerPrefix: []string{},
+ AllowFrom: FlexibleStringSlice{},
+ },
+ },
+ Providers: ProvidersConfig{
+ OpenAI: OpenAIProviderConfig{WebSearch: true},
+ },
+ ModelList: []ModelConfig{
+ // ============================================
+ // Add your API key to the model you want to use
+ // ============================================
+
+ // Zhipu AI (智谱) - https://open.bigmodel.cn/usercenter/apikeys
+ {
+ ModelName: "glm-4.7",
+ Model: "zhipu/glm-4.7",
+ APIBase: "https://open.bigmodel.cn/api/paas/v4",
+ APIKey: "",
+ },
+
+ // OpenAI - https://platform.openai.com/api-keys
+ {
+ ModelName: "gpt-5.2",
+ Model: "openai/gpt-5.2",
+ APIBase: "https://api.openai.com/v1",
+ APIKey: "",
+ },
+
+ // Anthropic Claude - https://console.anthropic.com/settings/keys
+ {
+ ModelName: "claude-sonnet-4.6",
+ Model: "anthropic/claude-sonnet-4.6",
+ APIBase: "https://api.anthropic.com/v1",
+ APIKey: "",
+ },
+
+ // DeepSeek - https://platform.deepseek.com/
+ {
+ ModelName: "deepseek-chat",
+ Model: "deepseek/deepseek-chat",
+ APIBase: "https://api.deepseek.com/v1",
+ APIKey: "",
+ },
+
+ // Google Gemini - https://ai.google.dev/
+ {
+ ModelName: "gemini-2.0-flash",
+ Model: "gemini/gemini-2.0-flash-exp",
+ APIBase: "https://generativelanguage.googleapis.com/v1beta",
+ APIKey: "",
+ },
+
+ // Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey
+ {
+ ModelName: "qwen-plus",
+ Model: "qwen/qwen-plus",
+ APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
+ APIKey: "",
+ },
+
+ // Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys
+ {
+ ModelName: "moonshot-v1-8k",
+ Model: "moonshot/moonshot-v1-8k",
+ APIBase: "https://api.moonshot.cn/v1",
+ APIKey: "",
+ },
+
+ // Groq - https://console.groq.com/keys
+ {
+ ModelName: "llama-3.3-70b",
+ Model: "groq/llama-3.3-70b-versatile",
+ APIBase: "https://api.groq.com/openai/v1",
+ APIKey: "",
+ },
+
+ // OpenRouter (100+ models) - https://openrouter.ai/keys
+ {
+ ModelName: "openrouter-auto",
+ Model: "openrouter/auto",
+ APIBase: "https://openrouter.ai/api/v1",
+ APIKey: "",
+ },
+ {
+ ModelName: "openrouter-gpt-5.2",
+ Model: "openrouter/openai/gpt-5.2",
+ APIBase: "https://openrouter.ai/api/v1",
+ APIKey: "",
+ },
+
+ // NVIDIA - https://build.nvidia.com/
+ {
+ ModelName: "nemotron-4-340b",
+ Model: "nvidia/nemotron-4-340b-instruct",
+ APIBase: "https://integrate.api.nvidia.com/v1",
+ APIKey: "",
+ },
+
+ // Cerebras - https://inference.cerebras.ai/
+ {
+ ModelName: "cerebras-llama-3.3-70b",
+ Model: "cerebras/llama-3.3-70b",
+ APIBase: "https://api.cerebras.ai/v1",
+ APIKey: "",
+ },
+
+ // Volcengine (火山引擎) - https://console.volcengine.com/ark
+ {
+ ModelName: "doubao-pro",
+ Model: "volcengine/doubao-pro-32k",
+ APIBase: "https://ark.cn-beijing.volces.com/api/v3",
+ APIKey: "",
+ },
+
+ // ShengsuanYun (神算云)
+ {
+ ModelName: "deepseek-v3",
+ Model: "shengsuanyun/deepseek-v3",
+ APIBase: "https://api.shengsuanyun.com/v1",
+ APIKey: "",
+ },
+
+ // Antigravity (Google Cloud Code Assist) - OAuth only
+ {
+ ModelName: "gemini-flash",
+ Model: "antigravity/gemini-3-flash",
+ AuthMethod: "oauth",
+ },
+
+ // GitHub Copilot - https://github.com/settings/tokens
+ {
+ ModelName: "copilot-gpt-5.2",
+ Model: "github-copilot/gpt-5.2",
+ APIBase: "http://localhost:4321",
+ AuthMethod: "oauth",
+ },
+
+ // Ollama (local) - https://ollama.com
+ {
+ ModelName: "llama3",
+ Model: "ollama/llama3",
+ APIBase: "http://localhost:11434/v1",
+ APIKey: "ollama",
+ },
+
+ // VLLM (local) - http://localhost:8000
+ {
+ ModelName: "local-model",
+ Model: "vllm/custom-model",
+ APIBase: "http://localhost:8000/v1",
+ APIKey: "",
+ },
+ },
+ Gateway: GatewayConfig{
+ Host: "0.0.0.0",
+ Port: 18790,
+ },
+ Tools: ToolsConfig{
+ Web: WebToolsConfig{
+ Brave: BraveConfig{
+ Enabled: false,
+ APIKey: "",
+ MaxResults: 5,
+ },
+ DuckDuckGo: DuckDuckGoConfig{
+ Enabled: true,
+ MaxResults: 5,
+ },
+ Perplexity: PerplexityConfig{
+ Enabled: false,
+ APIKey: "",
+ MaxResults: 5,
+ },
+ },
+ Cron: CronToolsConfig{
+ ExecTimeoutMinutes: 5,
+ },
+ },
+ Heartbeat: HeartbeatConfig{
+ Enabled: true,
+ Interval: 30,
+ },
+ Devices: DevicesConfig{
+ Enabled: false,
+ MonitorUSB: true,
+ },
+ }
+}
diff --git a/pkg/config/migration.go b/pkg/config/migration.go
new file mode 100644
index 000000000..689e2312f
--- /dev/null
+++ b/pkg/config/migration.go
@@ -0,0 +1,353 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package config
+
+import (
+ "slices"
+ "strings"
+)
+
+// buildModelWithProtocol constructs a model string with protocol prefix.
+// If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is.
+// Otherwise, the protocol prefix is added.
+func buildModelWithProtocol(protocol, model string) string {
+ if strings.Contains(model, "/") {
+ // Model already has a protocol prefix, return as-is
+ return model
+ }
+ return protocol + "/" + model
+}
+
+// providerMigrationConfig defines how to migrate a provider from old config to new format.
+type providerMigrationConfig struct {
+ // providerNames are the possible names used in agents.defaults.provider
+ providerNames []string
+ // protocol is the protocol prefix for the model field
+ protocol string
+ // buildConfig creates the ModelConfig from ProviderConfig
+ buildConfig func(p ProvidersConfig) (ModelConfig, bool)
+}
+
+// ConvertProvidersToModelList converts the old ProvidersConfig to a slice of ModelConfig.
+// This enables backward compatibility with existing configurations.
+// It preserves the user's configured model from agents.defaults.model when possible.
+func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
+ if cfg == nil {
+ return nil
+ }
+
+ // Get user's configured provider and model
+ userProvider := strings.ToLower(cfg.Agents.Defaults.Provider)
+ userModel := cfg.Agents.Defaults.Model
+
+ p := cfg.Providers
+
+ var result []ModelConfig
+
+ // Track if we've applied the legacy model name fix (only for first provider)
+ legacyModelNameApplied := false
+
+ // Define migration rules for each provider
+ migrations := []providerMigrationConfig{
+ {
+ providerNames: []string{"openai", "gpt"},
+ protocol: "openai",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.OpenAI.APIKey == "" && p.OpenAI.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "openai",
+ Model: "openai/gpt-5.2",
+ APIKey: p.OpenAI.APIKey,
+ APIBase: p.OpenAI.APIBase,
+ Proxy: p.OpenAI.Proxy,
+ AuthMethod: p.OpenAI.AuthMethod,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"anthropic", "claude"},
+ protocol: "anthropic",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Anthropic.APIKey == "" && p.Anthropic.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "anthropic",
+ Model: "anthropic/claude-sonnet-4.6",
+ APIKey: p.Anthropic.APIKey,
+ APIBase: p.Anthropic.APIBase,
+ Proxy: p.Anthropic.Proxy,
+ AuthMethod: p.Anthropic.AuthMethod,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"openrouter"},
+ protocol: "openrouter",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.OpenRouter.APIKey == "" && p.OpenRouter.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "openrouter",
+ Model: "openrouter/auto",
+ APIKey: p.OpenRouter.APIKey,
+ APIBase: p.OpenRouter.APIBase,
+ Proxy: p.OpenRouter.Proxy,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"groq"},
+ protocol: "groq",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Groq.APIKey == "" && p.Groq.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "groq",
+ Model: "groq/llama-3.1-70b-versatile",
+ APIKey: p.Groq.APIKey,
+ APIBase: p.Groq.APIBase,
+ Proxy: p.Groq.Proxy,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"zhipu", "glm"},
+ protocol: "zhipu",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Zhipu.APIKey == "" && p.Zhipu.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "zhipu",
+ Model: "zhipu/glm-4",
+ APIKey: p.Zhipu.APIKey,
+ APIBase: p.Zhipu.APIBase,
+ Proxy: p.Zhipu.Proxy,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"vllm"},
+ protocol: "vllm",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.VLLM.APIKey == "" && p.VLLM.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "vllm",
+ Model: "vllm/auto",
+ APIKey: p.VLLM.APIKey,
+ APIBase: p.VLLM.APIBase,
+ Proxy: p.VLLM.Proxy,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"gemini", "google"},
+ protocol: "gemini",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Gemini.APIKey == "" && p.Gemini.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "gemini",
+ Model: "gemini/gemini-pro",
+ APIKey: p.Gemini.APIKey,
+ APIBase: p.Gemini.APIBase,
+ Proxy: p.Gemini.Proxy,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"nvidia"},
+ protocol: "nvidia",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Nvidia.APIKey == "" && p.Nvidia.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "nvidia",
+ Model: "nvidia/meta/llama-3.1-8b-instruct",
+ APIKey: p.Nvidia.APIKey,
+ APIBase: p.Nvidia.APIBase,
+ Proxy: p.Nvidia.Proxy,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"ollama"},
+ protocol: "ollama",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Ollama.APIKey == "" && p.Ollama.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "ollama",
+ Model: "ollama/llama3",
+ APIKey: p.Ollama.APIKey,
+ APIBase: p.Ollama.APIBase,
+ Proxy: p.Ollama.Proxy,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"moonshot", "kimi"},
+ protocol: "moonshot",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Moonshot.APIKey == "" && p.Moonshot.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "moonshot",
+ Model: "moonshot/kimi",
+ APIKey: p.Moonshot.APIKey,
+ APIBase: p.Moonshot.APIBase,
+ Proxy: p.Moonshot.Proxy,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"shengsuanyun"},
+ protocol: "shengsuanyun",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.ShengSuanYun.APIKey == "" && p.ShengSuanYun.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "shengsuanyun",
+ Model: "shengsuanyun/auto",
+ APIKey: p.ShengSuanYun.APIKey,
+ APIBase: p.ShengSuanYun.APIBase,
+ Proxy: p.ShengSuanYun.Proxy,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"deepseek"},
+ protocol: "deepseek",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.DeepSeek.APIKey == "" && p.DeepSeek.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "deepseek",
+ Model: "deepseek/deepseek-chat",
+ APIKey: p.DeepSeek.APIKey,
+ APIBase: p.DeepSeek.APIBase,
+ Proxy: p.DeepSeek.Proxy,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"cerebras"},
+ protocol: "cerebras",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Cerebras.APIKey == "" && p.Cerebras.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "cerebras",
+ Model: "cerebras/llama-3.3-70b",
+ APIKey: p.Cerebras.APIKey,
+ APIBase: p.Cerebras.APIBase,
+ Proxy: p.Cerebras.Proxy,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"volcengine", "doubao"},
+ protocol: "volcengine",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "volcengine",
+ Model: "volcengine/doubao-pro",
+ APIKey: p.VolcEngine.APIKey,
+ APIBase: p.VolcEngine.APIBase,
+ Proxy: p.VolcEngine.Proxy,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"github_copilot", "copilot"},
+ protocol: "github-copilot",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" && p.GitHubCopilot.ConnectMode == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "github-copilot",
+ Model: "github-copilot/gpt-5.2",
+ APIBase: p.GitHubCopilot.APIBase,
+ ConnectMode: p.GitHubCopilot.ConnectMode,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"antigravity"},
+ protocol: "antigravity",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Antigravity.APIKey == "" && p.Antigravity.AuthMethod == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "antigravity",
+ Model: "antigravity/gemini-2.0-flash",
+ APIKey: p.Antigravity.APIKey,
+ AuthMethod: p.Antigravity.AuthMethod,
+ }, true
+ },
+ },
+ {
+ providerNames: []string{"qwen", "tongyi"},
+ protocol: "qwen",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Qwen.APIKey == "" && p.Qwen.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "qwen",
+ Model: "qwen/qwen-max",
+ APIKey: p.Qwen.APIKey,
+ APIBase: p.Qwen.APIBase,
+ Proxy: p.Qwen.Proxy,
+ }, true
+ },
+ },
+ }
+
+ // Process each provider migration
+ for _, m := range migrations {
+ mc, ok := m.buildConfig(p)
+ if !ok {
+ continue
+ }
+
+ // Check if this is the user's configured provider
+ if slices.Contains(m.providerNames, userProvider) && userModel != "" {
+ // Use the user's configured model instead of default
+ mc.Model = buildModelWithProtocol(m.protocol, userModel)
+ } else if userProvider == "" && userModel != "" && !legacyModelNameApplied {
+ // Legacy config: no explicit provider field but model is specified
+ // Use userModel as ModelName for the FIRST provider so GetModelConfig(model) can find it
+ // This maintains backward compatibility with old configs that relied on implicit provider selection
+ mc.ModelName = userModel
+ mc.Model = buildModelWithProtocol(m.protocol, userModel)
+ legacyModelNameApplied = true
+ }
+
+ result = append(result, mc)
+ }
+
+ return result
+}
diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go
new file mode 100644
index 000000000..b9a333f9e
--- /dev/null
+++ b/pkg/config/migration_test.go
@@ -0,0 +1,551 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package config
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
+ cfg := &Config{
+ Providers: ProvidersConfig{
+ OpenAI: OpenAIProviderConfig{
+ ProviderConfig: ProviderConfig{
+ APIKey: "sk-test-key",
+ APIBase: "https://custom.api.com/v1",
+ },
+ },
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 1 {
+ t.Fatalf("len(result) = %d, want 1", len(result))
+ }
+
+ if result[0].ModelName != "openai" {
+ t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
+ }
+ if result[0].Model != "openai/gpt-5.2" {
+ t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.2")
+ }
+ if result[0].APIKey != "sk-test-key" {
+ t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key")
+ }
+}
+
+func TestConvertProvidersToModelList_Anthropic(t *testing.T) {
+ cfg := &Config{
+ Providers: ProvidersConfig{
+ Anthropic: ProviderConfig{
+ APIKey: "ant-key",
+ APIBase: "https://custom.anthropic.com",
+ },
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 1 {
+ t.Fatalf("len(result) = %d, want 1", len(result))
+ }
+
+ if result[0].ModelName != "anthropic" {
+ t.Errorf("ModelName = %q, want %q", result[0].ModelName, "anthropic")
+ }
+ if result[0].Model != "anthropic/claude-sonnet-4.6" {
+ t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-sonnet-4.6")
+ }
+}
+
+func TestConvertProvidersToModelList_Multiple(t *testing.T) {
+ cfg := &Config{
+ Providers: ProvidersConfig{
+ OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}},
+ Groq: ProviderConfig{APIKey: "groq-key"},
+ Zhipu: ProviderConfig{APIKey: "zhipu-key"},
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 3 {
+ t.Fatalf("len(result) = %d, want 3", len(result))
+ }
+
+ // Check that all providers are present
+ found := make(map[string]bool)
+ for _, mc := range result {
+ found[mc.ModelName] = true
+ }
+
+ for _, name := range []string{"openai", "groq", "zhipu"} {
+ if !found[name] {
+ t.Errorf("Missing provider %q in result", name)
+ }
+ }
+}
+
+func TestConvertProvidersToModelList_Empty(t *testing.T) {
+ cfg := &Config{
+ Providers: ProvidersConfig{},
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 0 {
+ t.Errorf("len(result) = %d, want 0", len(result))
+ }
+}
+
+func TestConvertProvidersToModelList_Nil(t *testing.T) {
+ result := ConvertProvidersToModelList(nil)
+
+ if result != nil {
+ t.Errorf("result = %v, want nil", result)
+ }
+}
+
+func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
+ cfg := &Config{
+ Providers: ProvidersConfig{
+ OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "key1"}},
+ Anthropic: ProviderConfig{APIKey: "key2"},
+ OpenRouter: ProviderConfig{APIKey: "key3"},
+ Groq: ProviderConfig{APIKey: "key4"},
+ Zhipu: ProviderConfig{APIKey: "key5"},
+ VLLM: ProviderConfig{APIKey: "key6"},
+ Gemini: ProviderConfig{APIKey: "key7"},
+ Nvidia: ProviderConfig{APIKey: "key8"},
+ Ollama: ProviderConfig{APIKey: "key9"},
+ Moonshot: ProviderConfig{APIKey: "key10"},
+ ShengSuanYun: ProviderConfig{APIKey: "key11"},
+ DeepSeek: ProviderConfig{APIKey: "key12"},
+ Cerebras: ProviderConfig{APIKey: "key13"},
+ VolcEngine: ProviderConfig{APIKey: "key14"},
+ GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
+ Antigravity: ProviderConfig{AuthMethod: "oauth"},
+ Qwen: ProviderConfig{APIKey: "key17"},
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ // All 17 providers should be converted
+ if len(result) != 17 {
+ t.Errorf("len(result) = %d, want 17", len(result))
+ }
+}
+
+func TestConvertProvidersToModelList_Proxy(t *testing.T) {
+ cfg := &Config{
+ Providers: ProvidersConfig{
+ OpenAI: OpenAIProviderConfig{
+ ProviderConfig: ProviderConfig{
+ APIKey: "key",
+ Proxy: "http://proxy:8080",
+ },
+ },
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 1 {
+ t.Fatalf("len(result) = %d, want 1", len(result))
+ }
+
+ if result[0].Proxy != "http://proxy:8080" {
+ t.Errorf("Proxy = %q, want %q", result[0].Proxy, "http://proxy:8080")
+ }
+}
+
+func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
+ cfg := &Config{
+ Providers: ProvidersConfig{
+ OpenAI: OpenAIProviderConfig{
+ ProviderConfig: ProviderConfig{
+ AuthMethod: "oauth",
+ },
+ },
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 0 {
+ t.Errorf("len(result) = %d, want 0 (AuthMethod alone should not create entry)", len(result))
+ }
+}
+
+// Tests for preserving user's configured model during migration
+
+func TestConvertProvidersToModelList_PreservesUserModel_DeepSeek(t *testing.T) {
+ cfg := &Config{
+ Agents: AgentsConfig{
+ Defaults: AgentDefaults{
+ Provider: "deepseek",
+ Model: "deepseek-reasoner",
+ },
+ },
+ Providers: ProvidersConfig{
+ DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 1 {
+ t.Fatalf("len(result) = %d, want 1", len(result))
+ }
+
+ // Should use user's model, not default
+ if result[0].Model != "deepseek/deepseek-reasoner" {
+ t.Errorf("Model = %q, want %q (user's configured model)", result[0].Model, "deepseek/deepseek-reasoner")
+ }
+}
+
+func TestConvertProvidersToModelList_PreservesUserModel_OpenAI(t *testing.T) {
+ cfg := &Config{
+ Agents: AgentsConfig{
+ Defaults: AgentDefaults{
+ Provider: "openai",
+ Model: "gpt-4-turbo",
+ },
+ },
+ Providers: ProvidersConfig{
+ OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}},
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 1 {
+ t.Fatalf("len(result) = %d, want 1", len(result))
+ }
+
+ if result[0].Model != "openai/gpt-4-turbo" {
+ t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-4-turbo")
+ }
+}
+
+func TestConvertProvidersToModelList_PreservesUserModel_Anthropic(t *testing.T) {
+ cfg := &Config{
+ Agents: AgentsConfig{
+ Defaults: AgentDefaults{
+ Provider: "claude", // alternative name
+ Model: "claude-opus-4-20250514",
+ },
+ },
+ Providers: ProvidersConfig{
+ Anthropic: ProviderConfig{APIKey: "sk-ant"},
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 1 {
+ t.Fatalf("len(result) = %d, want 1", len(result))
+ }
+
+ if result[0].Model != "anthropic/claude-opus-4-20250514" {
+ t.Errorf("Model = %q, want %q", result[0].Model, "anthropic/claude-opus-4-20250514")
+ }
+}
+
+func TestConvertProvidersToModelList_PreservesUserModel_Qwen(t *testing.T) {
+ cfg := &Config{
+ Agents: AgentsConfig{
+ Defaults: AgentDefaults{
+ Provider: "qwen",
+ Model: "qwen-plus",
+ },
+ },
+ Providers: ProvidersConfig{
+ Qwen: ProviderConfig{APIKey: "sk-qwen"},
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 1 {
+ t.Fatalf("len(result) = %d, want 1", len(result))
+ }
+
+ if result[0].Model != "qwen/qwen-plus" {
+ t.Errorf("Model = %q, want %q", result[0].Model, "qwen/qwen-plus")
+ }
+}
+
+func TestConvertProvidersToModelList_UsesDefaultWhenNoUserModel(t *testing.T) {
+ cfg := &Config{
+ Agents: AgentsConfig{
+ Defaults: AgentDefaults{
+ Provider: "deepseek",
+ Model: "", // no model specified
+ },
+ },
+ Providers: ProvidersConfig{
+ DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 1 {
+ t.Fatalf("len(result) = %d, want 1", len(result))
+ }
+
+ // Should use default model
+ if result[0].Model != "deepseek/deepseek-chat" {
+ t.Errorf("Model = %q, want %q (default)", result[0].Model, "deepseek/deepseek-chat")
+ }
+}
+
+func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *testing.T) {
+ cfg := &Config{
+ Agents: AgentsConfig{
+ Defaults: AgentDefaults{
+ Provider: "deepseek",
+ Model: "deepseek-reasoner",
+ },
+ },
+ Providers: ProvidersConfig{
+ OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "sk-openai"}},
+ DeepSeek: ProviderConfig{APIKey: "sk-deepseek"},
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 2 {
+ t.Fatalf("len(result) = %d, want 2", len(result))
+ }
+
+ // Find each provider and verify model
+ for _, mc := range result {
+ switch mc.ModelName {
+ case "openai":
+ if mc.Model != "openai/gpt-5.2" {
+ t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.2")
+ }
+ case "deepseek":
+ if mc.Model != "deepseek/deepseek-reasoner" {
+ t.Errorf("DeepSeek Model = %q, want %q (user's)", mc.Model, "deepseek/deepseek-reasoner")
+ }
+ }
+ }
+}
+
+func TestConvertProvidersToModelList_ProviderNameAliases(t *testing.T) {
+ tests := []struct {
+ providerAlias string
+ expectedModel string
+ provider ProviderConfig
+ }{
+ {"gpt", "openai/gpt-4-custom", ProviderConfig{APIKey: "key"}},
+ {"claude", "anthropic/claude-custom", ProviderConfig{APIKey: "key"}},
+ {"doubao", "volcengine/doubao-custom", ProviderConfig{APIKey: "key"}},
+ {"tongyi", "qwen/qwen-custom", ProviderConfig{APIKey: "key"}},
+ {"kimi", "moonshot/kimi-custom", ProviderConfig{APIKey: "key"}},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.providerAlias, func(t *testing.T) {
+ cfg := &Config{
+ Agents: AgentsConfig{
+ Defaults: AgentDefaults{
+ Provider: tt.providerAlias,
+ Model: strings.TrimPrefix(tt.expectedModel, tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1]),
+ },
+ },
+ Providers: ProvidersConfig{},
+ }
+
+ // Set the appropriate provider config
+ switch tt.providerAlias {
+ case "gpt":
+ cfg.Providers.OpenAI = OpenAIProviderConfig{ProviderConfig: tt.provider}
+ case "claude":
+ cfg.Providers.Anthropic = tt.provider
+ case "doubao":
+ cfg.Providers.VolcEngine = tt.provider
+ case "tongyi":
+ cfg.Providers.Qwen = tt.provider
+ case "kimi":
+ cfg.Providers.Moonshot = tt.provider
+ }
+
+ // Need to fix the model name in config
+ cfg.Agents.Defaults.Model = strings.TrimPrefix(tt.expectedModel, tt.expectedModel[:strings.Index(tt.expectedModel, "/")+1])
+
+ result := ConvertProvidersToModelList(cfg)
+ if len(result) != 1 {
+ t.Fatalf("len(result) = %d, want 1", len(result))
+ }
+
+ // Extract just the model ID part (after the first /)
+ expectedModelID := tt.expectedModel
+ if result[0].Model != expectedModelID {
+ t.Errorf("Model = %q, want %q", result[0].Model, expectedModelID)
+ }
+ })
+ }
+}
+
+// Test for backward compatibility: single provider without explicit provider field
+// This matches the legacy config pattern where users only set model, not provider
+
+func TestConvertProvidersToModelList_NoProviderField_SingleProvider(t *testing.T) {
+ // This matches the user's actual config:
+ // - No provider field set
+ // - model = "glm-4.7"
+ // - Only zhipu has API key configured
+ cfg := &Config{
+ Agents: AgentsConfig{
+ Defaults: AgentDefaults{
+ Provider: "", // Not set
+ Model: "glm-4.7",
+ },
+ },
+ Providers: ProvidersConfig{
+ Zhipu: ProviderConfig{APIKey: "test-zhipu-key"},
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 1 {
+ t.Fatalf("len(result) = %d, want 1", len(result))
+ }
+
+ // ModelName should be the user's model value for backward compatibility
+ if result[0].ModelName != "glm-4.7" {
+ t.Errorf("ModelName = %q, want %q (user's model for backward compatibility)", result[0].ModelName, "glm-4.7")
+ }
+
+ // Model should use the user's model with protocol prefix
+ if result[0].Model != "zhipu/glm-4.7" {
+ t.Errorf("Model = %q, want %q", result[0].Model, "zhipu/glm-4.7")
+ }
+}
+
+func TestConvertProvidersToModelList_NoProviderField_MultipleProviders(t *testing.T) {
+ // When multiple providers are configured but no provider field is set,
+ // the FIRST provider (in migration order) will use userModel as ModelName
+ // for backward compatibility with legacy implicit provider selection
+ cfg := &Config{
+ Agents: AgentsConfig{
+ Defaults: AgentDefaults{
+ Provider: "", // Not set
+ Model: "some-model",
+ },
+ },
+ Providers: ProvidersConfig{
+ OpenAI: OpenAIProviderConfig{ProviderConfig: ProviderConfig{APIKey: "openai-key"}},
+ Zhipu: ProviderConfig{APIKey: "zhipu-key"},
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 2 {
+ t.Fatalf("len(result) = %d, want 2", len(result))
+ }
+
+ // The first provider (OpenAI in migration order) should use userModel as ModelName
+ // This ensures GetModelConfig("some-model") will find it
+ if result[0].ModelName != "some-model" {
+ t.Errorf("First provider ModelName = %q, want %q", result[0].ModelName, "some-model")
+ }
+
+ // Other providers should use provider name as ModelName
+ if result[1].ModelName != "zhipu" {
+ t.Errorf("Second provider ModelName = %q, want %q", result[1].ModelName, "zhipu")
+ }
+}
+
+func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
+ // Edge case: no provider, no model
+ cfg := &Config{
+ Agents: AgentsConfig{
+ Defaults: AgentDefaults{
+ Provider: "",
+ Model: "",
+ },
+ },
+ Providers: ProvidersConfig{
+ Zhipu: ProviderConfig{APIKey: "zhipu-key"},
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 1 {
+ t.Fatalf("len(result) = %d, want 1", len(result))
+ }
+
+ // Should use default provider name since no model is specified
+ if result[0].ModelName != "zhipu" {
+ t.Errorf("ModelName = %q, want %q", result[0].ModelName, "zhipu")
+ }
+}
+
+// Tests for buildModelWithProtocol helper function
+
+func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
+ result := buildModelWithProtocol("openai", "gpt-5.2")
+ if result != "openai/gpt-5.2" {
+ t.Errorf("buildModelWithProtocol(openai, gpt-5.2) = %q, want %q", result, "openai/gpt-5.2")
+ }
+}
+
+func TestBuildModelWithProtocol_AlreadyHasPrefix(t *testing.T) {
+ result := buildModelWithProtocol("openrouter", "openrouter/auto")
+ if result != "openrouter/auto" {
+ t.Errorf("buildModelWithProtocol(openrouter, openrouter/auto) = %q, want %q", result, "openrouter/auto")
+ }
+}
+
+func TestBuildModelWithProtocol_DifferentPrefix(t *testing.T) {
+ result := buildModelWithProtocol("anthropic", "openrouter/claude-sonnet-4.6")
+ if result != "openrouter/claude-sonnet-4.6" {
+ t.Errorf("buildModelWithProtocol(anthropic, openrouter/claude-sonnet-4.6) = %q, want %q", result, "openrouter/claude-sonnet-4.6")
+ }
+}
+
+// Test for legacy config with protocol prefix in model name
+func TestConvertProvidersToModelList_LegacyModelWithProtocolPrefix(t *testing.T) {
+ cfg := &Config{
+ Agents: AgentsConfig{
+ Defaults: AgentDefaults{
+ Provider: "", // No explicit provider
+ Model: "openrouter/auto", // Model already has protocol prefix
+ },
+ },
+ Providers: ProvidersConfig{
+ OpenRouter: ProviderConfig{APIKey: "sk-or-test"},
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) < 1 {
+ t.Fatalf("len(result) = %d, want at least 1", len(result))
+ }
+
+ // First provider should use userModel as ModelName for backward compatibility
+ if result[0].ModelName != "openrouter/auto" {
+ t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openrouter/auto")
+ }
+
+ // Model should NOT have duplicated prefix
+ if result[0].Model != "openrouter/auto" {
+ t.Errorf("Model = %q, want %q (should not duplicate prefix)", result[0].Model, "openrouter/auto")
+ }
+}
diff --git a/pkg/config/model_config_test.go b/pkg/config/model_config_test.go
new file mode 100644
index 000000000..3c411dc0f
--- /dev/null
+++ b/pkg/config/model_config_test.go
@@ -0,0 +1,235 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package config
+
+import (
+ "strings"
+ "sync"
+ "testing"
+)
+
+func TestGetModelConfig_Found(t *testing.T) {
+ cfg := &Config{
+ ModelList: []ModelConfig{
+ {ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"},
+ {ModelName: "other-model", Model: "anthropic/claude", APIKey: "key2"},
+ },
+ }
+
+ result, err := cfg.GetModelConfig("test-model")
+ if err != nil {
+ t.Fatalf("GetModelConfig() error = %v", err)
+ }
+ if result.Model != "openai/gpt-4o" {
+ t.Errorf("Model = %q, want %q", result.Model, "openai/gpt-4o")
+ }
+}
+
+func TestGetModelConfig_NotFound(t *testing.T) {
+ cfg := &Config{
+ ModelList: []ModelConfig{
+ {ModelName: "test-model", Model: "openai/gpt-4o", APIKey: "key1"},
+ },
+ }
+
+ _, err := cfg.GetModelConfig("nonexistent")
+ if err == nil {
+ t.Fatal("GetModelConfig() expected error for nonexistent model")
+ }
+}
+
+func TestGetModelConfig_EmptyList(t *testing.T) {
+ cfg := &Config{
+ ModelList: []ModelConfig{},
+ }
+
+ _, err := cfg.GetModelConfig("any-model")
+ if err == nil {
+ t.Fatal("GetModelConfig() expected error for empty model list")
+ }
+}
+
+func TestGetModelConfig_RoundRobin(t *testing.T) {
+ cfg := &Config{
+ ModelList: []ModelConfig{
+ {ModelName: "lb-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
+ {ModelName: "lb-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
+ {ModelName: "lb-model", Model: "openai/gpt-4o-3", APIKey: "key3"},
+ },
+ }
+
+ // Test round-robin distribution
+ results := make(map[string]int)
+ for i := 0; i < 30; i++ {
+ result, err := cfg.GetModelConfig("lb-model")
+ if err != nil {
+ t.Fatalf("GetModelConfig() error = %v", err)
+ }
+ results[result.Model]++
+ }
+
+ // Each model should appear roughly 10 times (30 calls / 3 models)
+ for model, count := range results {
+ if count < 5 || count > 15 {
+ t.Errorf("Model %s appeared %d times, expected ~10", model, count)
+ }
+ }
+}
+
+func TestGetModelConfig_Concurrent(t *testing.T) {
+ cfg := &Config{
+ ModelList: []ModelConfig{
+ {ModelName: "concurrent-model", Model: "openai/gpt-4o-1", APIKey: "key1"},
+ {ModelName: "concurrent-model", Model: "openai/gpt-4o-2", APIKey: "key2"},
+ },
+ }
+
+ const goroutines = 100
+ const iterations = 10
+
+ var wg sync.WaitGroup
+ errors := make(chan error, goroutines*iterations)
+
+ for i := 0; i < goroutines; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < iterations; j++ {
+ _, err := cfg.GetModelConfig("concurrent-model")
+ if err != nil {
+ errors <- err
+ }
+ }
+ }()
+ }
+
+ wg.Wait()
+ close(errors)
+
+ for err := range errors {
+ t.Errorf("Concurrent GetModelConfig() error: %v", err)
+ }
+}
+
+func TestModelConfig_Validate(t *testing.T) {
+ tests := []struct {
+ name string
+ config ModelConfig
+ wantErr bool
+ }{
+ {
+ name: "valid config",
+ config: ModelConfig{
+ ModelName: "test",
+ Model: "openai/gpt-4o",
+ },
+ wantErr: false,
+ },
+ {
+ name: "missing model_name",
+ config: ModelConfig{
+ Model: "openai/gpt-4o",
+ },
+ wantErr: true,
+ },
+ {
+ name: "missing model",
+ config: ModelConfig{
+ ModelName: "test",
+ },
+ wantErr: true,
+ },
+ {
+ name: "empty config",
+ config: ModelConfig{},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.config.Validate()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ })
+ }
+}
+
+func TestConfig_ValidateModelList(t *testing.T) {
+ tests := []struct {
+ name string
+ config *Config
+ wantErr bool
+ errMsg string // partial error message to check
+ }{
+ {
+ name: "valid list",
+ config: &Config{
+ ModelList: []ModelConfig{
+ {ModelName: "test1", Model: "openai/gpt-4o"},
+ {ModelName: "test2", Model: "anthropic/claude"},
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "invalid entry",
+ config: &Config{
+ ModelList: []ModelConfig{
+ {ModelName: "test1", Model: "openai/gpt-4o"},
+ {ModelName: "", Model: "anthropic/claude"}, // missing model_name
+ },
+ },
+ wantErr: true,
+ errMsg: "model_name is required",
+ },
+ {
+ name: "empty list",
+ config: &Config{
+ ModelList: []ModelConfig{},
+ },
+ wantErr: false,
+ },
+ {
+ // Load balancing: multiple entries with same model_name are allowed
+ name: "duplicate model_name for load balancing",
+ config: &Config{
+ ModelList: []ModelConfig{
+ {ModelName: "gpt-4", Model: "openai/gpt-4o", APIKey: "key1"},
+ {ModelName: "gpt-4", Model: "openai/gpt-4-turbo", APIKey: "key2"},
+ },
+ },
+ wantErr: false, // Changed: duplicates are allowed for load balancing
+ },
+ {
+ // Load balancing: non-adjacent entries with same model_name are also allowed
+ name: "duplicate model_name non-adjacent for load balancing",
+ config: &Config{
+ ModelList: []ModelConfig{
+ {ModelName: "model-a", Model: "openai/gpt-4o"},
+ {ModelName: "model-b", Model: "anthropic/claude"},
+ {ModelName: "model-a", Model: "openai/gpt-4-turbo"},
+ },
+ },
+ wantErr: false, // Changed: duplicates are allowed for load balancing
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.config.ValidateModelList()
+ if (err != nil) != tt.wantErr {
+ t.Errorf("ValidateModelList() error = %v, wantErr %v", err, tt.wantErr)
+ }
+ if err != nil && tt.errMsg != "" {
+ if !strings.Contains(err.Error(), tt.errMsg) {
+ t.Errorf("ValidateModelList() error = %v, want error containing %q", err, tt.errMsg)
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/constants/channels.go b/pkg/constants/channels.go
index 3e3df3839..0a46e6cd9 100644
--- a/pkg/constants/channels.go
+++ b/pkg/constants/channels.go
@@ -1,15 +1,16 @@
// Package constants provides shared constants across the codebase.
package constants
-// InternalChannels defines channels that are used for internal communication
+// internalChannels defines channels that are used for internal communication
// and should not be exposed to external users or recorded as last active channel.
-var InternalChannels = map[string]bool{
- "cli": true,
- "system": true,
- "subagent": true,
+var internalChannels = map[string]struct{}{
+ "cli": {},
+ "system": {},
+ "subagent": {},
}
// IsInternalChannel returns true if the channel is an internal channel.
func IsInternalChannel(channel string) bool {
- return InternalChannels[channel]
+ _, found := internalChannels[channel]
+ return found
}
diff --git a/pkg/cron/service.go b/pkg/cron/service.go
index ddd680e74..9f62c743b 100644
--- a/pkg/cron/service.go
+++ b/pkg/cron/service.go
@@ -340,7 +340,7 @@ func (cs *CronService) saveStoreUnsafe() error {
return err
}
- return os.WriteFile(cs.storePath, data, 0644)
+ return os.WriteFile(cs.storePath, data, 0600)
}
func (cs *CronService) AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to string) (*CronJob, error) {
diff --git a/pkg/cron/service_test.go b/pkg/cron/service_test.go
new file mode 100644
index 000000000..53d69f6a9
--- /dev/null
+++ b/pkg/cron/service_test.go
@@ -0,0 +1,38 @@
+package cron
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+func TestSaveStore_FilePermissions(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("file permission bits are not enforced on Windows")
+ }
+
+ tmpDir := t.TempDir()
+ storePath := filepath.Join(tmpDir, "cron", "jobs.json")
+
+ cs := NewCronService(storePath, nil)
+
+ _, err := cs.AddJob("test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", false, "cli", "direct")
+ if err != nil {
+ t.Fatalf("AddJob failed: %v", err)
+ }
+
+ info, err := os.Stat(storePath)
+ if err != nil {
+ t.Fatalf("Stat failed: %v", err)
+ }
+
+ perm := info.Mode().Perm()
+ if perm != 0600 {
+ t.Errorf("cron store has permission %04o, want 0600", perm)
+ }
+}
+
+func int64Ptr(v int64) *int64 {
+ return &v
+}
diff --git a/pkg/health/server.go b/pkg/health/server.go
new file mode 100644
index 000000000..77b36034d
--- /dev/null
+++ b/pkg/health/server.go
@@ -0,0 +1,164 @@
+package health
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+)
+
+type Server struct {
+ server *http.Server
+ mu sync.RWMutex
+ ready bool
+ checks map[string]Check
+ startTime time.Time
+}
+
+type Check struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Message string `json:"message,omitempty"`
+ Timestamp time.Time `json:"timestamp"`
+}
+
+type StatusResponse struct {
+ Status string `json:"status"`
+ Uptime string `json:"uptime"`
+ Checks map[string]Check `json:"checks,omitempty"`
+}
+
+func NewServer(host string, port int) *Server {
+ mux := http.NewServeMux()
+ s := &Server{
+ ready: false,
+ checks: make(map[string]Check),
+ startTime: time.Now(),
+ }
+
+ mux.HandleFunc("/health", s.healthHandler)
+ mux.HandleFunc("/ready", s.readyHandler)
+
+ addr := fmt.Sprintf("%s:%d", host, port)
+ s.server = &http.Server{
+ Addr: addr,
+ Handler: mux,
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 5 * time.Second,
+ }
+
+ return s
+}
+
+func (s *Server) Start() error {
+ s.mu.Lock()
+ s.ready = true
+ s.mu.Unlock()
+ return s.server.ListenAndServe()
+}
+
+func (s *Server) StartContext(ctx context.Context) error {
+ s.mu.Lock()
+ s.ready = true
+ s.mu.Unlock()
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- s.server.ListenAndServe()
+ }()
+
+ select {
+ case err := <-errCh:
+ return err
+ case <-ctx.Done():
+ return s.server.Shutdown(context.Background())
+ }
+}
+
+func (s *Server) Stop(ctx context.Context) error {
+ s.mu.Lock()
+ s.ready = false
+ s.mu.Unlock()
+ return s.server.Shutdown(ctx)
+}
+
+func (s *Server) SetReady(ready bool) {
+ s.mu.Lock()
+ s.ready = ready
+ s.mu.Unlock()
+}
+
+func (s *Server) RegisterCheck(name string, checkFn func() (bool, string)) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ status, msg := checkFn()
+ s.checks[name] = Check{
+ Name: name,
+ Status: statusString(status),
+ Message: msg,
+ Timestamp: time.Now(),
+ }
+}
+
+func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ uptime := time.Since(s.startTime)
+ resp := StatusResponse{
+ Status: "ok",
+ Uptime: uptime.String(),
+ }
+
+ json.NewEncoder(w).Encode(resp)
+}
+
+func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+
+ s.mu.RLock()
+ ready := s.ready
+ checks := make(map[string]Check)
+ for k, v := range s.checks {
+ checks[k] = v
+ }
+ s.mu.RUnlock()
+
+ if !ready {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ json.NewEncoder(w).Encode(StatusResponse{
+ Status: "not ready",
+ Checks: checks,
+ })
+ return
+ }
+
+ for _, check := range checks {
+ if check.Status == "fail" {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ json.NewEncoder(w).Encode(StatusResponse{
+ Status: "not ready",
+ Checks: checks,
+ })
+ return
+ }
+ }
+
+ w.WriteHeader(http.StatusOK)
+ uptime := time.Since(s.startTime)
+ json.NewEncoder(w).Encode(StatusResponse{
+ Status: "ready",
+ Uptime: uptime.String(),
+ Checks: checks,
+ })
+}
+
+func statusString(ok bool) string {
+ if ok {
+ return "ok"
+ }
+ return "fail"
+}
diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go
index 9c1e36359..b01bb80e3 100644
--- a/pkg/migrate/config.go
+++ b/pkg/migrate/config.go
@@ -12,13 +12,16 @@ import (
)
var supportedProviders = map[string]bool{
- "anthropic": true,
- "openai": true,
- "openrouter": true,
- "groq": true,
- "zhipu": true,
- "vllm": true,
- "gemini": true,
+ "anthropic": true,
+ "openai": true,
+ "openrouter": true,
+ "groq": true,
+ "zhipu": true,
+ "vllm": true,
+ "gemini": true,
+ "qwen": true,
+ "deepseek": true,
+ "github_copilot": true,
}
var supportedChannels = map[string]bool{
@@ -76,7 +79,7 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
cfg.Agents.Defaults.MaxTokens = int(v)
}
if v, ok := getFloat(defaults, "temperature"); ok {
- cfg.Agents.Defaults.Temperature = v
+ cfg.Agents.Defaults.Temperature = &v
}
if v, ok := getFloat(defaults, "max_tool_iterations"); ok {
cfg.Agents.Defaults.MaxToolIterations = int(v)
@@ -108,7 +111,10 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
case "anthropic":
cfg.Providers.Anthropic = pc
case "openai":
- cfg.Providers.OpenAI = pc
+ cfg.Providers.OpenAI = config.OpenAIProviderConfig{
+ ProviderConfig: pc,
+ WebSearch: getBoolOrDefault(pMap, "web_search", true),
+ }
case "openrouter":
cfg.Providers.OpenRouter = pc
case "groq":
@@ -253,6 +259,15 @@ func MergeConfig(existing, incoming *config.Config) *config.Config {
if existing.Providers.Gemini.APIKey == "" {
existing.Providers.Gemini = incoming.Providers.Gemini
}
+ if existing.Providers.DeepSeek.APIKey == "" {
+ existing.Providers.DeepSeek = incoming.Providers.DeepSeek
+ }
+ if existing.Providers.GitHubCopilot.APIBase == "" {
+ existing.Providers.GitHubCopilot = incoming.Providers.GitHubCopilot
+ }
+ if existing.Providers.Qwen.APIKey == "" {
+ existing.Providers.Qwen = incoming.Providers.Qwen
+ }
if !existing.Channels.Telegram.Enabled && incoming.Channels.Telegram.Enabled {
existing.Channels.Telegram = incoming.Channels.Telegram
@@ -363,6 +378,13 @@ func getBool(data map[string]interface{}, key string) (bool, bool) {
return b, ok
}
+func getBoolOrDefault(data map[string]interface{}, key string, defaultVal bool) bool {
+ if v, ok := getBool(data, key); ok {
+ return v
+ }
+ return defaultVal
+}
+
func getStringSlice(data map[string]interface{}, key string) []string {
v, ok := data[key]
if !ok {
diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go
index 67ced014c..9b0f2be3f 100644
--- a/pkg/migrate/migrate_test.go
+++ b/pkg/migrate/migrate_test.go
@@ -180,8 +180,8 @@ func TestConvertConfig(t *testing.T) {
t.Run("unsupported provider warning", func(t *testing.T) {
data := map[string]interface{}{
"providers": map[string]interface{}{
- "deepseek": map[string]interface{}{
- "api_key": "sk-deep-test",
+ "unknown_provider": map[string]interface{}{
+ "api_key": "sk-test",
},
},
}
@@ -193,7 +193,7 @@ func TestConvertConfig(t *testing.T) {
if len(warnings) != 1 {
t.Fatalf("expected 1 warning, got %d", len(warnings))
}
- if warnings[0] != "Provider 'deepseek' not supported in PicoClaw, skipping" {
+ if warnings[0] != "Provider 'unknown_provider' not supported in PicoClaw, skipping" {
t.Errorf("unexpected warning: %s", warnings[0])
}
})
@@ -275,8 +275,11 @@ func TestConvertConfig(t *testing.T) {
if cfg.Agents.Defaults.MaxTokens != 4096 {
t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 4096)
}
- if cfg.Agents.Defaults.Temperature != 0.5 {
- t.Errorf("Temperature = %f, want %f", cfg.Agents.Defaults.Temperature, 0.5)
+ if cfg.Agents.Defaults.Temperature == nil {
+ t.Fatalf("Temperature is nil, want %f", 0.5)
+ }
+ if *cfg.Agents.Defaults.Temperature != 0.5 {
+ t.Errorf("Temperature = %f, want %f", *cfg.Agents.Defaults.Temperature, 0.5)
}
if cfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" {
t.Errorf("Workspace = %q, want %q", cfg.Agents.Defaults.Workspace, "~/.picoclaw/workspace")
@@ -299,6 +302,24 @@ func TestConvertConfig(t *testing.T) {
})
}
+func TestSupportedProvidersCompatibility(t *testing.T) {
+ expected := []string{
+ "anthropic",
+ "openai",
+ "openrouter",
+ "groq",
+ "zhipu",
+ "vllm",
+ "gemini",
+ }
+
+ for _, provider := range expected {
+ if !supportedProviders[provider] {
+ t.Fatalf("supportedProviders missing expected key %q", provider)
+ }
+ }
+}
+
func TestMergeConfig(t *testing.T) {
t.Run("fills empty fields", func(t *testing.T) {
existing := config.DefaultConfig()
diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go
new file mode 100644
index 000000000..a27a25a2d
--- /dev/null
+++ b/pkg/providers/anthropic/provider.go
@@ -0,0 +1,248 @@
+package anthropicprovider
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "strings"
+
+ "github.com/anthropics/anthropic-sdk-go"
+ "github.com/anthropics/anthropic-sdk-go/option"
+ "github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
+)
+
+type ToolCall = protocoltypes.ToolCall
+type FunctionCall = protocoltypes.FunctionCall
+type LLMResponse = protocoltypes.LLMResponse
+type UsageInfo = protocoltypes.UsageInfo
+type Message = protocoltypes.Message
+type ToolDefinition = protocoltypes.ToolDefinition
+type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
+
+const defaultBaseURL = "https://api.anthropic.com"
+
+type Provider struct {
+ client *anthropic.Client
+ tokenSource func() (string, error)
+ baseURL string
+}
+
+func NewProvider(token string) *Provider {
+ return NewProviderWithBaseURL(token, "")
+}
+
+func NewProviderWithBaseURL(token, apiBase string) *Provider {
+ baseURL := normalizeBaseURL(apiBase)
+ client := anthropic.NewClient(
+ option.WithAuthToken(token),
+ option.WithBaseURL(baseURL),
+ )
+ return &Provider{
+ client: &client,
+ baseURL: baseURL,
+ }
+}
+
+func NewProviderWithClient(client *anthropic.Client) *Provider {
+ return &Provider{
+ client: client,
+ baseURL: defaultBaseURL,
+ }
+}
+
+func NewProviderWithTokenSource(token string, tokenSource func() (string, error)) *Provider {
+ return NewProviderWithTokenSourceAndBaseURL(token, tokenSource, "")
+}
+
+func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *Provider {
+ p := NewProviderWithBaseURL(token, apiBase)
+ p.tokenSource = tokenSource
+ return p
+}
+
+func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+ var opts []option.RequestOption
+ if p.tokenSource != nil {
+ tok, err := p.tokenSource()
+ if err != nil {
+ return nil, fmt.Errorf("refreshing token: %w", err)
+ }
+ opts = append(opts, option.WithAuthToken(tok))
+ }
+
+ params, err := buildParams(messages, tools, model, options)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := p.client.Messages.New(ctx, params, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("claude API call: %w", err)
+ }
+
+ return parseResponse(resp), nil
+}
+
+func (p *Provider) GetDefaultModel() string {
+ return "claude-sonnet-4.6"
+}
+
+func (p *Provider) BaseURL() string {
+ return p.baseURL
+}
+
+func buildParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) {
+ var system []anthropic.TextBlockParam
+ var anthropicMessages []anthropic.MessageParam
+
+ for _, msg := range messages {
+ switch msg.Role {
+ case "system":
+ system = append(system, anthropic.TextBlockParam{Text: msg.Content})
+ case "user":
+ if msg.ToolCallID != "" {
+ anthropicMessages = append(anthropicMessages,
+ anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
+ )
+ } else {
+ anthropicMessages = append(anthropicMessages,
+ anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),
+ )
+ }
+ case "assistant":
+ if len(msg.ToolCalls) > 0 {
+ var blocks []anthropic.ContentBlockParamUnion
+ if msg.Content != "" {
+ blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
+ }
+ for _, tc := range msg.ToolCalls {
+ blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
+ }
+ anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
+ } else {
+ anthropicMessages = append(anthropicMessages,
+ anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)),
+ )
+ }
+ case "tool":
+ anthropicMessages = append(anthropicMessages,
+ anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
+ )
+ }
+ }
+
+ maxTokens := int64(4096)
+ if mt, ok := options["max_tokens"].(int); ok {
+ maxTokens = int64(mt)
+ }
+
+ params := anthropic.MessageNewParams{
+ Model: anthropic.Model(model),
+ Messages: anthropicMessages,
+ MaxTokens: maxTokens,
+ }
+
+ if len(system) > 0 {
+ params.System = system
+ }
+
+ if temp, ok := options["temperature"].(float64); ok {
+ params.Temperature = anthropic.Float(temp)
+ }
+
+ if len(tools) > 0 {
+ params.Tools = translateTools(tools)
+ }
+
+ return params, nil
+}
+
+func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
+ result := make([]anthropic.ToolUnionParam, 0, len(tools))
+ for _, t := range tools {
+ tool := anthropic.ToolParam{
+ Name: t.Function.Name,
+ InputSchema: anthropic.ToolInputSchemaParam{
+ Properties: t.Function.Parameters["properties"],
+ },
+ }
+ if desc := t.Function.Description; desc != "" {
+ tool.Description = anthropic.String(desc)
+ }
+ if req, ok := t.Function.Parameters["required"].([]interface{}); ok {
+ required := make([]string, 0, len(req))
+ for _, r := range req {
+ if s, ok := r.(string); ok {
+ required = append(required, s)
+ }
+ }
+ tool.InputSchema.Required = required
+ }
+ result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
+ }
+ return result
+}
+
+func parseResponse(resp *anthropic.Message) *LLMResponse {
+ var content string
+ var toolCalls []ToolCall
+
+ for _, block := range resp.Content {
+ switch block.Type {
+ case "text":
+ tb := block.AsText()
+ content += tb.Text
+ case "tool_use":
+ tu := block.AsToolUse()
+ var args map[string]interface{}
+ if err := json.Unmarshal(tu.Input, &args); err != nil {
+ log.Printf("anthropic: failed to decode tool call input for %q: %v", tu.Name, err)
+ args = map[string]interface{}{"raw": string(tu.Input)}
+ }
+ toolCalls = append(toolCalls, ToolCall{
+ ID: tu.ID,
+ Name: tu.Name,
+ Arguments: args,
+ })
+ }
+ }
+
+ finishReason := "stop"
+ switch resp.StopReason {
+ case anthropic.StopReasonToolUse:
+ finishReason = "tool_calls"
+ case anthropic.StopReasonMaxTokens:
+ finishReason = "length"
+ case anthropic.StopReasonEndTurn:
+ finishReason = "stop"
+ }
+
+ return &LLMResponse{
+ Content: content,
+ ToolCalls: toolCalls,
+ FinishReason: finishReason,
+ Usage: &UsageInfo{
+ PromptTokens: int(resp.Usage.InputTokens),
+ CompletionTokens: int(resp.Usage.OutputTokens),
+ TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens),
+ },
+ }
+}
+
+func normalizeBaseURL(apiBase string) string {
+ base := strings.TrimSpace(apiBase)
+ if base == "" {
+ return defaultBaseURL
+ }
+
+ base = strings.TrimRight(base, "/")
+ if strings.HasSuffix(base, "/v1") {
+ base = strings.TrimSuffix(base, "/v1")
+ }
+ if base == "" {
+ return defaultBaseURL
+ }
+
+ return base
+}
diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go
new file mode 100644
index 000000000..08ac9c829
--- /dev/null
+++ b/pkg/providers/anthropic/provider_test.go
@@ -0,0 +1,265 @@
+package anthropicprovider
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+
+ "github.com/anthropics/anthropic-sdk-go"
+ anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
+)
+
+func TestBuildParams_BasicMessage(t *testing.T) {
+ messages := []Message{
+ {Role: "user", Content: "Hello"},
+ }
+ params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{
+ "max_tokens": 1024,
+ })
+ if err != nil {
+ t.Fatalf("buildParams() error: %v", err)
+ }
+ if string(params.Model) != "claude-sonnet-4.6" {
+ t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4.6")
+ }
+ if params.MaxTokens != 1024 {
+ t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
+ }
+ if len(params.Messages) != 1 {
+ t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
+ }
+}
+
+func TestBuildParams_SystemMessage(t *testing.T) {
+ messages := []Message{
+ {Role: "system", Content: "You are helpful"},
+ {Role: "user", Content: "Hi"},
+ }
+ params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{})
+ if err != nil {
+ t.Fatalf("buildParams() error: %v", err)
+ }
+ if len(params.System) != 1 {
+ t.Fatalf("len(System) = %d, want 1", len(params.System))
+ }
+ if params.System[0].Text != "You are helpful" {
+ t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful")
+ }
+ if len(params.Messages) != 1 {
+ t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
+ }
+}
+
+func TestBuildParams_ToolCallMessage(t *testing.T) {
+ messages := []Message{
+ {Role: "user", Content: "What's the weather?"},
+ {
+ Role: "assistant",
+ Content: "",
+ ToolCalls: []ToolCall{
+ {
+ ID: "call_1",
+ Name: "get_weather",
+ Arguments: map[string]interface{}{"city": "SF"},
+ },
+ },
+ },
+ {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
+ }
+ params, err := buildParams(messages, nil, "claude-sonnet-4.6", map[string]interface{}{})
+ if err != nil {
+ t.Fatalf("buildParams() error: %v", err)
+ }
+ if len(params.Messages) != 3 {
+ t.Fatalf("len(Messages) = %d, want 3", len(params.Messages))
+ }
+}
+
+func TestBuildParams_WithTools(t *testing.T) {
+ tools := []ToolDefinition{
+ {
+ Type: "function",
+ Function: ToolFunctionDefinition{
+ Name: "get_weather",
+ Description: "Get weather for a city",
+ Parameters: map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "city": map[string]interface{}{"type": "string"},
+ },
+ "required": []interface{}{"city"},
+ },
+ },
+ },
+ }
+ params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4.6", map[string]interface{}{})
+ if err != nil {
+ t.Fatalf("buildParams() error: %v", err)
+ }
+ if len(params.Tools) != 1 {
+ t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
+ }
+}
+
+func TestParseResponse_TextOnly(t *testing.T) {
+ resp := &anthropic.Message{
+ Content: []anthropic.ContentBlockUnion{},
+ Usage: anthropic.Usage{
+ InputTokens: 10,
+ OutputTokens: 20,
+ },
+ }
+ result := parseResponse(resp)
+ if result.Usage.PromptTokens != 10 {
+ t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens)
+ }
+ if result.Usage.CompletionTokens != 20 {
+ t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens)
+ }
+ if result.FinishReason != "stop" {
+ t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop")
+ }
+}
+
+func TestParseResponse_StopReasons(t *testing.T) {
+ tests := []struct {
+ stopReason anthropic.StopReason
+ want string
+ }{
+ {anthropic.StopReasonEndTurn, "stop"},
+ {anthropic.StopReasonMaxTokens, "length"},
+ {anthropic.StopReasonToolUse, "tool_calls"},
+ }
+ for _, tt := range tests {
+ resp := &anthropic.Message{
+ StopReason: tt.stopReason,
+ }
+ result := parseResponse(resp)
+ if result.FinishReason != tt.want {
+ t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want)
+ }
+ }
+}
+
+func TestProvider_ChatRoundTrip(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/messages" {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ if r.Header.Get("Authorization") != "Bearer test-token" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ var reqBody map[string]interface{}
+ json.NewDecoder(r.Body).Decode(&reqBody)
+
+ resp := map[string]interface{}{
+ "id": "msg_test",
+ "type": "message",
+ "role": "assistant",
+ "model": reqBody["model"],
+ "stop_reason": "end_turn",
+ "content": []map[string]interface{}{
+ {"type": "text", "text": "Hello! How can I help you?"},
+ },
+ "usage": map[string]interface{}{
+ "input_tokens": 15,
+ "output_tokens": 8,
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
+ messages := []Message{{Role: "user", Content: "Hello"}}
+ resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]interface{}{"max_tokens": 1024})
+ if err != nil {
+ t.Fatalf("Chat() error: %v", err)
+ }
+ if resp.Content != "Hello! How can I help you?" {
+ t.Errorf("Content = %q, want %q", resp.Content, "Hello! How can I help you?")
+ }
+ if resp.FinishReason != "stop" {
+ t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
+ }
+ if resp.Usage.PromptTokens != 15 {
+ t.Errorf("PromptTokens = %d, want 15", resp.Usage.PromptTokens)
+ }
+}
+
+func TestProvider_GetDefaultModel(t *testing.T) {
+ p := NewProvider("test-token")
+ if got := p.GetDefaultModel(); got != "claude-sonnet-4.6" {
+ t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4.6")
+ }
+}
+
+func TestProvider_NewProviderWithBaseURL_NormalizesV1Suffix(t *testing.T) {
+ p := NewProviderWithBaseURL("token", "https://api.anthropic.com/v1/")
+ if got := p.BaseURL(); got != "https://api.anthropic.com" {
+ t.Fatalf("BaseURL() = %q, want %q", got, "https://api.anthropic.com")
+ }
+}
+
+func TestProvider_ChatUsesTokenSource(t *testing.T) {
+ var requests int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v1/messages" {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ atomic.AddInt32(&requests, 1)
+
+ if got := r.Header.Get("Authorization"); got != "Bearer refreshed-token" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ var reqBody map[string]interface{}
+ json.NewDecoder(r.Body).Decode(&reqBody)
+
+ resp := map[string]interface{}{
+ "id": "msg_test",
+ "type": "message",
+ "role": "assistant",
+ "model": reqBody["model"],
+ "stop_reason": "end_turn",
+ "content": []map[string]interface{}{
+ {"type": "text", "text": "ok"},
+ },
+ "usage": map[string]interface{}{
+ "input_tokens": 1,
+ "output_tokens": 1,
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ p := NewProviderWithTokenSourceAndBaseURL("stale-token", func() (string, error) {
+ return "refreshed-token", nil
+ }, server.URL)
+
+ _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hello"}}, nil, "claude-sonnet-4.6", map[string]interface{}{})
+ if err != nil {
+ t.Fatalf("Chat() error: %v", err)
+ }
+ if got := atomic.LoadInt32(&requests); got != 1 {
+ t.Fatalf("requests = %d, want 1", got)
+ }
+}
+
+func createAnthropicTestClient(baseURL, token string) *anthropic.Client {
+ c := anthropic.NewClient(
+ anthropicoption.WithAuthToken(token),
+ anthropicoption.WithBaseURL(baseURL),
+ )
+ return &c
+}
diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/antigravity_provider.go
new file mode 100644
index 000000000..6c6bf7830
--- /dev/null
+++ b/pkg/providers/antigravity_provider.go
@@ -0,0 +1,827 @@
+package providers
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math/rand"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/auth"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+const (
+ antigravityBaseURL = "https://cloudcode-pa.googleapis.com"
+ antigravityDefaultModel = "gemini-3-flash"
+ antigravityUserAgent = "antigravity"
+ antigravityXGoogClient = "google-cloud-sdk vscode_cloudshelleditor/0.1"
+ antigravityVersion = "1.15.8"
+)
+
+// AntigravityProvider implements LLMProvider using Google's Cloud Code Assist (Antigravity) API.
+// This provider authenticates via Google OAuth and provides access to models like Claude and Gemini
+// through Google's infrastructure.
+type AntigravityProvider struct {
+ tokenSource func() (string, string, error) // Returns (accessToken, projectID, error)
+ httpClient *http.Client
+}
+
+// NewAntigravityProvider creates a new Antigravity provider using stored auth credentials.
+func NewAntigravityProvider() *AntigravityProvider {
+ return &AntigravityProvider{
+ tokenSource: createAntigravityTokenSource(),
+ httpClient: &http.Client{
+ Timeout: 120 * time.Second,
+ },
+ }
+}
+
+// Chat implements LLMProvider.Chat using the Cloud Code Assist v1internal API.
+// The v1internal endpoint wraps the standard Gemini request in an envelope with
+// project, model, request, requestType, userAgent, and requestId fields.
+func (p *AntigravityProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+ accessToken, projectID, err := p.tokenSource()
+ if err != nil {
+ return nil, fmt.Errorf("antigravity auth: %w", err)
+ }
+
+ if model == "" || model == "antigravity" || model == "google-antigravity" {
+ model = antigravityDefaultModel
+ }
+ // Strip provider prefixes if present
+ model = strings.TrimPrefix(model, "google-antigravity/")
+ model = strings.TrimPrefix(model, "antigravity/")
+
+ logger.DebugCF("provider.antigravity", "Starting chat", map[string]interface{}{
+ "model": model,
+ "project": projectID,
+ "requestId": fmt.Sprintf("agent-%d-%s", time.Now().UnixMilli(), randomString(9)),
+ })
+
+ // Build the inner Gemini-format request
+ innerRequest := p.buildRequest(messages, tools, model, options)
+
+ // Wrap in v1internal envelope (matches pi-ai SDK format)
+ envelope := map[string]interface{}{
+ "project": projectID,
+ "model": model,
+ "request": innerRequest,
+ "requestType": "agent",
+ "userAgent": antigravityUserAgent,
+ "requestId": fmt.Sprintf("agent-%d-%s", time.Now().UnixMilli(), randomString(9)),
+ }
+
+ bodyBytes, err := json.Marshal(envelope)
+ if err != nil {
+ return nil, fmt.Errorf("marshaling request: %w", err)
+ }
+
+ // Build API URL — uses Cloud Code Assist v1internal streaming endpoint
+ apiURL := fmt.Sprintf("%s/v1internal:streamGenerateContent?alt=sse", antigravityBaseURL)
+
+ req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(bodyBytes))
+ if err != nil {
+ return nil, fmt.Errorf("creating request: %w", err)
+ }
+
+ // Headers matching the pi-ai SDK antigravity format
+ clientMetadata, _ := json.Marshal(map[string]string{
+ "ideType": "IDE_UNSPECIFIED",
+ "platform": "PLATFORM_UNSPECIFIED",
+ "pluginType": "GEMINI",
+ })
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "text/event-stream")
+ req.Header.Set("User-Agent", fmt.Sprintf("antigravity/%s linux/amd64", antigravityVersion))
+ req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient)
+ req.Header.Set("Client-Metadata", string(clientMetadata))
+
+ resp, err := p.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("antigravity API call: %w", err)
+ }
+ defer resp.Body.Close()
+
+ respBody, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("reading response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ logger.ErrorCF("provider.antigravity", "API call failed", map[string]interface{}{
+ "status_code": resp.StatusCode,
+ "response": string(respBody),
+ "model": model,
+ })
+
+ return nil, p.parseAntigravityError(resp.StatusCode, respBody)
+ }
+
+ // Response is always SSE from streamGenerateContent — each line is "data: {...}"
+ // with a "response" wrapper containing the standard Gemini response
+ llmResp, err := p.parseSSEResponse(string(respBody))
+ if err != nil {
+ return nil, err
+ }
+
+ // Check for empty response (some models might return valid success but empty text)
+ if llmResp.Content == "" && len(llmResp.ToolCalls) == 0 {
+ return nil, fmt.Errorf("antigravity: model returned an empty response (this model might be invalid or restricted)")
+ }
+
+ return llmResp, nil
+}
+
+// GetDefaultModel returns the default model identifier.
+func (p *AntigravityProvider) GetDefaultModel() string {
+ return antigravityDefaultModel
+}
+
+// --- Request building ---
+
+type antigravityRequest struct {
+ Contents []antigravityContent `json:"contents"`
+ Tools []antigravityTool `json:"tools,omitempty"`
+ SystemPrompt *antigravitySystemPrompt `json:"systemInstruction,omitempty"`
+ Config *antigravityGenConfig `json:"generationConfig,omitempty"`
+}
+
+type antigravityContent struct {
+ Role string `json:"role"`
+ Parts []antigravityPart `json:"parts"`
+}
+
+type antigravityPart struct {
+ Text string `json:"text,omitempty"`
+ ThoughtSignature string `json:"thoughtSignature,omitempty"`
+ ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
+ FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"`
+ FunctionResponse *antigravityFunctionResponse `json:"functionResponse,omitempty"`
+}
+
+type antigravityFunctionCall struct {
+ Name string `json:"name"`
+ Args map[string]interface{} `json:"args"`
+}
+
+type antigravityFunctionResponse struct {
+ Name string `json:"name"`
+ Response map[string]interface{} `json:"response"`
+}
+
+type antigravityTool struct {
+ FunctionDeclarations []antigravityFuncDecl `json:"functionDeclarations"`
+}
+
+type antigravityFuncDecl struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Parameters interface{} `json:"parameters,omitempty"`
+}
+
+type antigravitySystemPrompt struct {
+ Parts []antigravityPart `json:"parts"`
+}
+
+type antigravityGenConfig struct {
+ MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
+ Temperature float64 `json:"temperature,omitempty"`
+}
+
+func (p *AntigravityProvider) buildRequest(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) antigravityRequest {
+ req := antigravityRequest{}
+ toolCallNames := make(map[string]string)
+
+ // Build contents from messages
+ for _, msg := range messages {
+ switch msg.Role {
+ case "system":
+ req.SystemPrompt = &antigravitySystemPrompt{
+ Parts: []antigravityPart{{Text: msg.Content}},
+ }
+ case "user":
+ if msg.ToolCallID != "" {
+ toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
+ // Tool result
+ req.Contents = append(req.Contents, antigravityContent{
+ Role: "user",
+ Parts: []antigravityPart{{
+ FunctionResponse: &antigravityFunctionResponse{
+ Name: toolName,
+ Response: map[string]interface{}{
+ "result": msg.Content,
+ },
+ },
+ }},
+ })
+ } else {
+ req.Contents = append(req.Contents, antigravityContent{
+ Role: "user",
+ Parts: []antigravityPart{{Text: msg.Content}},
+ })
+ }
+ case "assistant":
+ content := antigravityContent{
+ Role: "model",
+ }
+ if msg.Content != "" {
+ content.Parts = append(content.Parts, antigravityPart{Text: msg.Content})
+ }
+ for _, tc := range msg.ToolCalls {
+ toolName, toolArgs, thoughtSignature := normalizeStoredToolCall(tc)
+ if toolName == "" {
+ logger.WarnCF("provider.antigravity", "Skipping tool call with empty name in history", map[string]interface{}{
+ "tool_call_id": tc.ID,
+ })
+ continue
+ }
+ if tc.ID != "" {
+ toolCallNames[tc.ID] = toolName
+ }
+ content.Parts = append(content.Parts, antigravityPart{
+ ThoughtSignature: thoughtSignature,
+ ThoughtSignatureSnake: thoughtSignature,
+ FunctionCall: &antigravityFunctionCall{
+ Name: toolName,
+ Args: toolArgs,
+ },
+ })
+ }
+ if len(content.Parts) > 0 {
+ req.Contents = append(req.Contents, content)
+ }
+ case "tool":
+ toolName := resolveToolResponseName(msg.ToolCallID, toolCallNames)
+ req.Contents = append(req.Contents, antigravityContent{
+ Role: "user",
+ Parts: []antigravityPart{{
+ FunctionResponse: &antigravityFunctionResponse{
+ Name: toolName,
+ Response: map[string]interface{}{
+ "result": msg.Content,
+ },
+ },
+ }},
+ })
+ }
+ }
+
+ // Build tools (sanitize schemas for Gemini compatibility)
+ if len(tools) > 0 {
+ var funcDecls []antigravityFuncDecl
+ for _, t := range tools {
+ if t.Type != "function" {
+ continue
+ }
+ params := sanitizeSchemaForGemini(t.Function.Parameters)
+ funcDecls = append(funcDecls, antigravityFuncDecl{
+ Name: t.Function.Name,
+ Description: t.Function.Description,
+ Parameters: params,
+ })
+ }
+ if len(funcDecls) > 0 {
+ req.Tools = []antigravityTool{{FunctionDeclarations: funcDecls}}
+ }
+ }
+
+ // Generation config
+ config := &antigravityGenConfig{}
+ if val, ok := options["max_tokens"]; ok {
+ if maxTokens, ok := val.(int); ok && maxTokens > 0 {
+ config.MaxOutputTokens = maxTokens
+ } else if maxTokens, ok := val.(float64); ok && maxTokens > 0 {
+ config.MaxOutputTokens = int(maxTokens)
+ }
+ }
+ if temp, ok := options["temperature"].(float64); ok {
+ config.Temperature = temp
+ }
+ if config.MaxOutputTokens > 0 || config.Temperature > 0 {
+ req.Config = config
+ }
+
+ return req
+}
+
+func normalizeStoredToolCall(tc ToolCall) (string, map[string]interface{}, string) {
+ name := tc.Name
+ args := tc.Arguments
+ thoughtSignature := ""
+
+ if name == "" && tc.Function != nil {
+ name = tc.Function.Name
+ thoughtSignature = tc.Function.ThoughtSignature
+ } else if tc.Function != nil {
+ thoughtSignature = tc.Function.ThoughtSignature
+ }
+
+ if args == nil {
+ args = map[string]interface{}{}
+ }
+
+ if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" {
+ var parsed map[string]interface{}
+ if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil {
+ args = parsed
+ }
+ }
+
+ return name, args, thoughtSignature
+}
+
+func resolveToolResponseName(toolCallID string, toolCallNames map[string]string) string {
+ if toolCallID == "" {
+ return ""
+ }
+
+ if name, ok := toolCallNames[toolCallID]; ok && name != "" {
+ return name
+ }
+
+ return inferToolNameFromCallID(toolCallID)
+}
+
+func inferToolNameFromCallID(toolCallID string) string {
+ if !strings.HasPrefix(toolCallID, "call_") {
+ return toolCallID
+ }
+
+ rest := strings.TrimPrefix(toolCallID, "call_")
+ if idx := strings.LastIndex(rest, "_"); idx > 0 {
+ candidate := rest[:idx]
+ if candidate != "" {
+ return candidate
+ }
+ }
+
+ return toolCallID
+}
+
+// --- Response parsing ---
+
+type antigravityJSONResponse struct {
+ Candidates []struct {
+ Content struct {
+ Parts []struct {
+ Text string `json:"text,omitempty"`
+ ThoughtSignature string `json:"thoughtSignature,omitempty"`
+ ThoughtSignatureSnake string `json:"thought_signature,omitempty"`
+ FunctionCall *antigravityFunctionCall `json:"functionCall,omitempty"`
+ } `json:"parts"`
+ Role string `json:"role"`
+ } `json:"content"`
+ FinishReason string `json:"finishReason"`
+ } `json:"candidates"`
+ UsageMetadata struct {
+ PromptTokenCount int `json:"promptTokenCount"`
+ CandidatesTokenCount int `json:"candidatesTokenCount"`
+ TotalTokenCount int `json:"totalTokenCount"`
+ } `json:"usageMetadata"`
+}
+
+func (p *AntigravityProvider) parseJSONResponse(body []byte) (*LLMResponse, error) {
+ var resp antigravityJSONResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ return nil, fmt.Errorf("parsing antigravity response: %w", err)
+ }
+
+ if len(resp.Candidates) == 0 {
+ return nil, fmt.Errorf("antigravity: no candidates in response")
+ }
+
+ candidate := resp.Candidates[0]
+ var contentParts []string
+ var toolCalls []ToolCall
+
+ for _, part := range candidate.Content.Parts {
+ if part.Text != "" {
+ contentParts = append(contentParts, part.Text)
+ }
+ if part.FunctionCall != nil {
+ argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
+ toolCalls = append(toolCalls, ToolCall{
+ ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
+ Name: part.FunctionCall.Name,
+ Arguments: part.FunctionCall.Args,
+ Function: &FunctionCall{
+ Name: part.FunctionCall.Name,
+ Arguments: string(argumentsJSON),
+ ThoughtSignature: extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake),
+ },
+ })
+ }
+ }
+
+ finishReason := "stop"
+ if len(toolCalls) > 0 {
+ finishReason = "tool_calls"
+ }
+ if candidate.FinishReason == "MAX_TOKENS" {
+ finishReason = "length"
+ }
+
+ var usage *UsageInfo
+ if resp.UsageMetadata.TotalTokenCount > 0 {
+ usage = &UsageInfo{
+ PromptTokens: resp.UsageMetadata.PromptTokenCount,
+ CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
+ TotalTokens: resp.UsageMetadata.TotalTokenCount,
+ }
+ }
+
+ return &LLMResponse{
+ Content: strings.Join(contentParts, ""),
+ ToolCalls: toolCalls,
+ FinishReason: finishReason,
+ Usage: usage,
+ }, nil
+}
+
+func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error) {
+ var contentParts []string
+ var toolCalls []ToolCall
+ var usage *UsageInfo
+ var finishReason string
+
+ scanner := bufio.NewScanner(strings.NewReader(body))
+ for scanner.Scan() {
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ data := strings.TrimPrefix(line, "data: ")
+ if data == "[DONE]" {
+ break
+ }
+
+ // v1internal SSE wraps the Gemini response in a "response" field
+ var sseChunk struct {
+ Response antigravityJSONResponse `json:"response"`
+ }
+ if err := json.Unmarshal([]byte(data), &sseChunk); err != nil {
+ continue
+ }
+ resp := sseChunk.Response
+
+ for _, candidate := range resp.Candidates {
+ for _, part := range candidate.Content.Parts {
+ if part.Text != "" {
+ contentParts = append(contentParts, part.Text)
+ }
+ if part.FunctionCall != nil {
+ argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
+ toolCalls = append(toolCalls, ToolCall{
+ ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
+ Name: part.FunctionCall.Name,
+ Arguments: part.FunctionCall.Args,
+ Function: &FunctionCall{
+ Name: part.FunctionCall.Name,
+ Arguments: string(argumentsJSON),
+ ThoughtSignature: extractPartThoughtSignature(part.ThoughtSignature, part.ThoughtSignatureSnake),
+ },
+ })
+ }
+ }
+ if candidate.FinishReason != "" {
+ finishReason = candidate.FinishReason
+ }
+ }
+
+ if resp.UsageMetadata.TotalTokenCount > 0 {
+ usage = &UsageInfo{
+ PromptTokens: resp.UsageMetadata.PromptTokenCount,
+ CompletionTokens: resp.UsageMetadata.CandidatesTokenCount,
+ TotalTokens: resp.UsageMetadata.TotalTokenCount,
+ }
+ }
+ }
+
+ mappedFinish := "stop"
+ if len(toolCalls) > 0 {
+ mappedFinish = "tool_calls"
+ }
+ if finishReason == "MAX_TOKENS" {
+ mappedFinish = "length"
+ }
+
+ return &LLMResponse{
+ Content: strings.Join(contentParts, ""),
+ ToolCalls: toolCalls,
+ FinishReason: mappedFinish,
+ Usage: usage,
+ }, nil
+}
+
+func extractPartThoughtSignature(thoughtSignature string, thoughtSignatureSnake string) string {
+ if thoughtSignature != "" {
+ return thoughtSignature
+ }
+ if thoughtSignatureSnake != "" {
+ return thoughtSignatureSnake
+ }
+ return ""
+}
+
+// --- Schema sanitization ---
+
+// Google/Gemini doesn't support many JSON Schema keywords that other providers accept.
+var geminiUnsupportedKeywords = map[string]bool{
+ "patternProperties": true,
+ "additionalProperties": true,
+ "$schema": true,
+ "$id": true,
+ "$ref": true,
+ "$defs": true,
+ "definitions": true,
+ "examples": true,
+ "minLength": true,
+ "maxLength": true,
+ "minimum": true,
+ "maximum": true,
+ "multipleOf": true,
+ "pattern": true,
+ "format": true,
+ "minItems": true,
+ "maxItems": true,
+ "uniqueItems": true,
+ "minProperties": true,
+ "maxProperties": true,
+}
+
+func sanitizeSchemaForGemini(schema map[string]interface{}) map[string]interface{} {
+ if schema == nil {
+ return nil
+ }
+
+ result := make(map[string]interface{})
+ for k, v := range schema {
+ if geminiUnsupportedKeywords[k] {
+ continue
+ }
+ // Recursively sanitize nested objects
+ switch val := v.(type) {
+ case map[string]interface{}:
+ result[k] = sanitizeSchemaForGemini(val)
+ case []interface{}:
+ sanitized := make([]interface{}, len(val))
+ for i, item := range val {
+ if m, ok := item.(map[string]interface{}); ok {
+ sanitized[i] = sanitizeSchemaForGemini(m)
+ } else {
+ sanitized[i] = item
+ }
+ }
+ result[k] = sanitized
+ default:
+ result[k] = v
+ }
+ }
+
+ // Ensure top-level has type: "object" if properties are present
+ if _, hasProps := result["properties"]; hasProps {
+ if _, hasType := result["type"]; !hasType {
+ result["type"] = "object"
+ }
+ }
+
+ return result
+}
+
+// --- Token source ---
+
+func createAntigravityTokenSource() func() (string, string, error) {
+ return func() (string, string, error) {
+ cred, err := auth.GetCredential("google-antigravity")
+ if err != nil {
+ return "", "", fmt.Errorf("loading auth credentials: %w", err)
+ }
+ if cred == nil {
+ return "", "", fmt.Errorf("no credentials for google-antigravity. Run: picoclaw auth login --provider google-antigravity")
+ }
+
+ // Refresh if needed
+ if cred.NeedsRefresh() && cred.RefreshToken != "" {
+ oauthCfg := auth.GoogleAntigravityOAuthConfig()
+ refreshed, err := auth.RefreshAccessToken(cred, oauthCfg)
+ if err != nil {
+ return "", "", fmt.Errorf("refreshing token: %w", err)
+ }
+ refreshed.Email = cred.Email
+ if refreshed.ProjectID == "" {
+ refreshed.ProjectID = cred.ProjectID
+ }
+ if err := auth.SetCredential("google-antigravity", refreshed); err != nil {
+ return "", "", fmt.Errorf("saving refreshed token: %w", err)
+ }
+ cred = refreshed
+ }
+
+ if cred.IsExpired() {
+ return "", "", fmt.Errorf("antigravity credentials expired. Run: picoclaw auth login --provider google-antigravity")
+ }
+
+ projectID := cred.ProjectID
+ if projectID == "" {
+ // Try to fetch project ID from API
+ fetchedID, err := FetchAntigravityProjectID(cred.AccessToken)
+ if err != nil {
+ logger.WarnCF("provider.antigravity", "Could not fetch project ID, using fallback", map[string]interface{}{
+ "error": err.Error(),
+ })
+ projectID = "rising-fact-p41fc" // Default fallback (same as OpenCode)
+ } else {
+ projectID = fetchedID
+ cred.ProjectID = projectID
+ _ = auth.SetCredential("google-antigravity", cred)
+ }
+ }
+
+ return cred.AccessToken, projectID, nil
+ }
+}
+
+// FetchAntigravityProjectID retrieves the Google Cloud project ID from the loadCodeAssist endpoint.
+func FetchAntigravityProjectID(accessToken string) (string, error) {
+ reqBody, _ := json.Marshal(map[string]interface{}{
+ "metadata": map[string]interface{}{
+ "ideType": "IDE_UNSPECIFIED",
+ "platform": "PLATFORM_UNSPECIFIED",
+ "pluginType": "GEMINI",
+ },
+ })
+
+ req, err := http.NewRequest("POST", antigravityBaseURL+"/v1internal:loadCodeAssist", bytes.NewReader(reqBody))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", antigravityUserAgent)
+ req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient)
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("loadCodeAssist failed: %s", string(body))
+ }
+
+ var result struct {
+ CloudAICompanionProject string `json:"cloudaicompanionProject"`
+ }
+ if err := json.Unmarshal(body, &result); err != nil {
+ return "", err
+ }
+
+ if result.CloudAICompanionProject == "" {
+ return "", fmt.Errorf("no project ID in loadCodeAssist response")
+ }
+
+ return result.CloudAICompanionProject, nil
+}
+
+// FetchAntigravityModels fetches available models from the Cloud Code Assist API.
+func FetchAntigravityModels(accessToken, projectID string) ([]AntigravityModelInfo, error) {
+ reqBody, _ := json.Marshal(map[string]interface{}{
+ "project": projectID,
+ })
+
+ req, err := http.NewRequest("POST", antigravityBaseURL+"/v1internal:fetchAvailableModels", bytes.NewReader(reqBody))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", antigravityUserAgent)
+ req.Header.Set("X-Goog-Api-Client", antigravityXGoogClient)
+
+ client := &http.Client{Timeout: 15 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("fetchAvailableModels failed (HTTP %d): %s", resp.StatusCode, truncateString(string(body), 200))
+ }
+
+ var result struct {
+ Models map[string]struct {
+ DisplayName string `json:"displayName"`
+ QuotaInfo struct {
+ RemainingFraction interface{} `json:"remainingFraction"`
+ ResetTime string `json:"resetTime"`
+ IsExhausted bool `json:"isExhausted"`
+ } `json:"quotaInfo"`
+ } `json:"models"`
+ }
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("parsing models response: %w", err)
+ }
+
+ var models []AntigravityModelInfo
+ for id, info := range result.Models {
+ models = append(models, AntigravityModelInfo{
+ ID: id,
+ DisplayName: info.DisplayName,
+ IsExhausted: info.QuotaInfo.IsExhausted,
+ })
+ }
+
+ // Ensure gemini-3-flash-preview and gemini-3-flash are in the list if they aren't already
+ hasFlashPreview := false
+ hasFlash := false
+ for _, m := range models {
+ if m.ID == "gemini-3-flash-preview" {
+ hasFlashPreview = true
+ }
+ if m.ID == "gemini-3-flash" {
+ hasFlash = true
+ }
+ }
+ if !hasFlashPreview {
+ models = append(models, AntigravityModelInfo{
+ ID: "gemini-3-flash-preview",
+ DisplayName: "Gemini 3 Flash (Preview)",
+ })
+ }
+ if !hasFlash {
+ models = append(models, AntigravityModelInfo{
+ ID: "gemini-3-flash",
+ DisplayName: "Gemini 3 Flash",
+ })
+ }
+
+ return models, nil
+}
+
+type AntigravityModelInfo struct {
+ ID string `json:"id"`
+ DisplayName string `json:"display_name"`
+ IsExhausted bool `json:"is_exhausted"`
+}
+
+// --- Helpers ---
+
+func truncateString(s string, maxLen int) string {
+ if len(s) <= maxLen {
+ return s
+ }
+ return s[:maxLen] + "..."
+}
+
+func randomString(n int) string {
+ const letters = "abcdefghijklmnopqrstuvwxyz0123456789"
+ b := make([]byte, n)
+ for i := range b {
+ b[i] = letters[rand.Intn(len(letters))]
+ }
+ return string(b)
+}
+
+func (p *AntigravityProvider) parseAntigravityError(statusCode int, body []byte) error {
+ var errResp struct {
+ Error struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+ Status string `json:"status"`
+ Details []map[string]interface{} `json:"details"`
+ } `json:"error"`
+ }
+
+ if err := json.Unmarshal(body, &errResp); err != nil {
+ return fmt.Errorf("antigravity API error (HTTP %d): %s", statusCode, truncateString(string(body), 500))
+ }
+
+ msg := errResp.Error.Message
+ if statusCode == 429 {
+ // Try to extract quota reset info
+ for _, detail := range errResp.Error.Details {
+ if typeVal, ok := detail["@type"].(string); ok && strings.HasSuffix(typeVal, "ErrorInfo") {
+ if metadata, ok := detail["metadata"].(map[string]interface{}); ok {
+ if delay, ok := metadata["quotaResetDelay"].(string); ok {
+ return fmt.Errorf("antigravity rate limit exceeded: %s (reset in %s)", msg, delay)
+ }
+ }
+ }
+ }
+ return fmt.Errorf("antigravity rate limit exceeded: %s", msg)
+ }
+
+ return fmt.Errorf("antigravity API error (%s): %s", errResp.Error.Status, msg)
+}
diff --git a/pkg/providers/antigravity_provider_test.go b/pkg/providers/antigravity_provider_test.go
new file mode 100644
index 000000000..238765321
--- /dev/null
+++ b/pkg/providers/antigravity_provider_test.go
@@ -0,0 +1,56 @@
+package providers
+
+import "testing"
+
+func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) {
+ p := &AntigravityProvider{}
+
+ messages := []Message{
+ {
+ Role: "assistant",
+ ToolCalls: []ToolCall{{
+ ID: "call_read_file_123",
+ Function: &FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"README.md"}`,
+ },
+ }},
+ },
+ {
+ Role: "tool",
+ ToolCallID: "call_read_file_123",
+ Content: "ok",
+ },
+ }
+
+ req := p.buildRequest(messages, nil, "", nil)
+ if len(req.Contents) != 2 {
+ t.Fatalf("expected 2 contents, got %d", len(req.Contents))
+ }
+
+ modelPart := req.Contents[0].Parts[0]
+ if modelPart.FunctionCall == nil {
+ t.Fatal("expected functionCall in assistant message")
+ }
+ if modelPart.FunctionCall.Name != "read_file" {
+ t.Fatalf("expected functionCall name read_file, got %q", modelPart.FunctionCall.Name)
+ }
+ if got := modelPart.FunctionCall.Args["path"]; got != "README.md" {
+ t.Fatalf("expected functionCall args[path] to be README.md, got %v", got)
+ }
+
+ toolPart := req.Contents[1].Parts[0]
+ if toolPart.FunctionResponse == nil {
+ t.Fatal("expected functionResponse in tool message")
+ }
+ if toolPart.FunctionResponse.Name != "read_file" {
+ t.Fatalf("expected functionResponse name read_file, got %q", toolPart.FunctionResponse.Name)
+ }
+}
+
+func TestResolveToolResponseNameInfersNameFromGeneratedCallID(t *testing.T) {
+ got := resolveToolResponseName("call_search_docs_999", map[string]string{})
+ if got != "search_docs" {
+ t.Fatalf("expected inferred tool name search_docs, got %q", got)
+ }
+}
diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go
index a91795715..58ba3647d 100644
--- a/pkg/providers/claude_cli_provider.go
+++ b/pkg/providers/claude_cli_provider.go
@@ -171,68 +171,14 @@ func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse,
}, nil
}
-// extractToolCalls parses tool call JSON from the response text.
+// extractToolCalls delegates to the shared extractToolCallsFromText function.
func (p *ClaudeCliProvider) extractToolCalls(text string) []ToolCall {
- start := strings.Index(text, `{"tool_calls"`)
- if start == -1 {
- return nil
- }
-
- end := findMatchingBrace(text, start)
- if end == start {
- return nil
- }
-
- jsonStr := text[start:end]
-
- var wrapper struct {
- ToolCalls []struct {
- ID string `json:"id"`
- Type string `json:"type"`
- Function struct {
- Name string `json:"name"`
- Arguments string `json:"arguments"`
- } `json:"function"`
- } `json:"tool_calls"`
- }
-
- if err := json.Unmarshal([]byte(jsonStr), &wrapper); err != nil {
- return nil
- }
-
- var result []ToolCall
- for _, tc := range wrapper.ToolCalls {
- var args map[string]interface{}
- json.Unmarshal([]byte(tc.Function.Arguments), &args)
-
- result = append(result, ToolCall{
- ID: tc.ID,
- Type: tc.Type,
- Name: tc.Function.Name,
- Arguments: args,
- Function: &FunctionCall{
- Name: tc.Function.Name,
- Arguments: tc.Function.Arguments,
- },
- })
- }
-
- return result
+ return extractToolCallsFromText(text)
}
-// stripToolCallsJSON removes tool call JSON from response text.
+// stripToolCallsJSON delegates to the shared stripToolCallsFromText function.
func (p *ClaudeCliProvider) stripToolCallsJSON(text string) string {
- start := strings.Index(text, `{"tool_calls"`)
- if start == -1 {
- return text
- }
-
- end := findMatchingBrace(text, start)
- if end == start {
- return text
- }
-
- return strings.TrimSpace(text[:start] + text[end:])
+ return stripToolCallsFromText(text)
}
// findMatchingBrace finds the index after the closing brace matching the opening brace at pos.
diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go
index 063530deb..945f5bd4f 100644
--- a/pkg/providers/claude_cli_provider_test.go
+++ b/pkg/providers/claude_cli_provider_test.go
@@ -336,7 +336,7 @@ func TestChat_PassesModelFlag(t *testing.T) {
_, err := p.Chat(context.Background(), []Message{
{Role: "user", Content: "Hi"},
- }, nil, "claude-sonnet-4-5-20250929", nil)
+ }, nil, "claude-sonnet-4.6", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
@@ -346,7 +346,7 @@ func TestChat_PassesModelFlag(t *testing.T) {
if !strings.Contains(args, "--model") {
t.Errorf("CLI args missing --model, got: %s", args)
}
- if !strings.Contains(args, "claude-sonnet-4-5-20250929") {
+ if !strings.Contains(args, "claude-sonnet-4.6") {
t.Errorf("CLI args missing model name, got: %s", args)
}
}
@@ -416,10 +416,12 @@ func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {
func TestCreateProvider_ClaudeCli(t *testing.T) {
cfg := config.DefaultConfig()
- cfg.Agents.Defaults.Provider = "claude-cli"
- cfg.Agents.Defaults.Workspace = "/test/ws"
+ cfg.ModelList = []config.ModelConfig{
+ {ModelName: "claude-sonnet-4.6", Model: "claude-cli/claude-sonnet-4.6", Workspace: "/test/ws"},
+ }
+ cfg.Agents.Defaults.Model = "claude-sonnet-4.6"
- provider, err := CreateProvider(cfg)
+ provider, _, err := CreateProvider(cfg)
if err != nil {
t.Fatalf("CreateProvider(claude-cli) error = %v", err)
}
@@ -435,9 +437,12 @@ func TestCreateProvider_ClaudeCli(t *testing.T) {
func TestCreateProvider_ClaudeCode(t *testing.T) {
cfg := config.DefaultConfig()
- cfg.Agents.Defaults.Provider = "claude-code"
+ cfg.ModelList = []config.ModelConfig{
+ {ModelName: "claude-code", Model: "claude-cli/claude-code"},
+ }
+ cfg.Agents.Defaults.Model = "claude-code"
- provider, err := CreateProvider(cfg)
+ provider, _, err := CreateProvider(cfg)
if err != nil {
t.Fatalf("CreateProvider(claude-code) error = %v", err)
}
@@ -448,9 +453,12 @@ func TestCreateProvider_ClaudeCode(t *testing.T) {
func TestCreateProvider_ClaudeCodec(t *testing.T) {
cfg := config.DefaultConfig()
- cfg.Agents.Defaults.Provider = "claudecode"
+ cfg.ModelList = []config.ModelConfig{
+ {ModelName: "claudecode", Model: "claude-cli/claudecode"},
+ }
+ cfg.Agents.Defaults.Model = "claudecode"
- provider, err := CreateProvider(cfg)
+ provider, _, err := CreateProvider(cfg)
if err != nil {
t.Fatalf("CreateProvider(claudecode) error = %v", err)
}
@@ -461,10 +469,13 @@ func TestCreateProvider_ClaudeCodec(t *testing.T) {
func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) {
cfg := config.DefaultConfig()
- cfg.Agents.Defaults.Provider = "claude-cli"
+ cfg.ModelList = []config.ModelConfig{
+ {ModelName: "claude-cli", Model: "claude-cli/claude-sonnet"},
+ }
+ cfg.Agents.Defaults.Model = "claude-cli"
cfg.Agents.Defaults.Workspace = ""
- provider, err := CreateProvider(cfg)
+ provider, _, err := CreateProvider(cfg)
if err != nil {
t.Fatalf("CreateProvider error = %v", err)
}
diff --git a/pkg/providers/claude_provider.go b/pkg/providers/claude_provider.go
index ae6aca96d..3ca54d5a3 100644
--- a/pkg/providers/claude_provider.go
+++ b/pkg/providers/claude_provider.go
@@ -2,200 +2,58 @@ package providers
import (
"context"
- "encoding/json"
"fmt"
- "github.com/anthropics/anthropic-sdk-go"
- "github.com/anthropics/anthropic-sdk-go/option"
- "github.com/sipeed/picoclaw/pkg/auth"
+ anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic"
)
type ClaudeProvider struct {
- client *anthropic.Client
- tokenSource func() (string, error)
+ delegate *anthropicprovider.Provider
}
func NewClaudeProvider(token string) *ClaudeProvider {
- client := anthropic.NewClient(
- option.WithAuthToken(token),
- option.WithBaseURL("https://api.anthropic.com"),
- )
- return &ClaudeProvider{client: &client}
+ return &ClaudeProvider{
+ delegate: anthropicprovider.NewProvider(token),
+ }
+}
+
+func NewClaudeProviderWithBaseURL(token, apiBase string) *ClaudeProvider {
+ return &ClaudeProvider{
+ delegate: anthropicprovider.NewProviderWithBaseURL(token, apiBase),
+ }
}
func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider {
- p := NewClaudeProvider(token)
- p.tokenSource = tokenSource
- return p
+ return &ClaudeProvider{
+ delegate: anthropicprovider.NewProviderWithTokenSource(token, tokenSource),
+ }
+}
+
+func NewClaudeProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *ClaudeProvider {
+ return &ClaudeProvider{
+ delegate: anthropicprovider.NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase),
+ }
+}
+
+func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *ClaudeProvider {
+ return &ClaudeProvider{delegate: delegate}
}
func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
- var opts []option.RequestOption
- if p.tokenSource != nil {
- tok, err := p.tokenSource()
- if err != nil {
- return nil, fmt.Errorf("refreshing token: %w", err)
- }
- opts = append(opts, option.WithAuthToken(tok))
- }
-
- params, err := buildClaudeParams(messages, tools, model, options)
+ resp, err := p.delegate.Chat(ctx, messages, tools, model, options)
if err != nil {
return nil, err
}
-
- resp, err := p.client.Messages.New(ctx, params, opts...)
- if err != nil {
- return nil, fmt.Errorf("claude API call: %w", err)
- }
-
- return parseClaudeResponse(resp), nil
+ return resp, nil
}
func (p *ClaudeProvider) GetDefaultModel() string {
- return "claude-sonnet-4-5-20250929"
-}
-
-func buildClaudeParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) {
- var system []anthropic.TextBlockParam
- var anthropicMessages []anthropic.MessageParam
-
- for _, msg := range messages {
- switch msg.Role {
- case "system":
- system = append(system, anthropic.TextBlockParam{Text: msg.Content})
- case "user":
- if msg.ToolCallID != "" {
- anthropicMessages = append(anthropicMessages,
- anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
- )
- } else {
- anthropicMessages = append(anthropicMessages,
- anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)),
- )
- }
- case "assistant":
- if len(msg.ToolCalls) > 0 {
- var blocks []anthropic.ContentBlockParamUnion
- if msg.Content != "" {
- blocks = append(blocks, anthropic.NewTextBlock(msg.Content))
- }
- for _, tc := range msg.ToolCalls {
- blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name))
- }
- anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...))
- } else {
- anthropicMessages = append(anthropicMessages,
- anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)),
- )
- }
- case "tool":
- anthropicMessages = append(anthropicMessages,
- anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)),
- )
- }
- }
-
- maxTokens := int64(4096)
- if mt, ok := options["max_tokens"].(int); ok {
- maxTokens = int64(mt)
- }
-
- params := anthropic.MessageNewParams{
- Model: anthropic.Model(model),
- Messages: anthropicMessages,
- MaxTokens: maxTokens,
- }
-
- if len(system) > 0 {
- params.System = system
- }
-
- if temp, ok := options["temperature"].(float64); ok {
- params.Temperature = anthropic.Float(temp)
- }
-
- if len(tools) > 0 {
- params.Tools = translateToolsForClaude(tools)
- }
-
- return params, nil
-}
-
-func translateToolsForClaude(tools []ToolDefinition) []anthropic.ToolUnionParam {
- result := make([]anthropic.ToolUnionParam, 0, len(tools))
- for _, t := range tools {
- tool := anthropic.ToolParam{
- Name: t.Function.Name,
- InputSchema: anthropic.ToolInputSchemaParam{
- Properties: t.Function.Parameters["properties"],
- },
- }
- if desc := t.Function.Description; desc != "" {
- tool.Description = anthropic.String(desc)
- }
- if req, ok := t.Function.Parameters["required"].([]interface{}); ok {
- required := make([]string, 0, len(req))
- for _, r := range req {
- if s, ok := r.(string); ok {
- required = append(required, s)
- }
- }
- tool.InputSchema.Required = required
- }
- result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
- }
- return result
-}
-
-func parseClaudeResponse(resp *anthropic.Message) *LLMResponse {
- var content string
- var toolCalls []ToolCall
-
- for _, block := range resp.Content {
- switch block.Type {
- case "text":
- tb := block.AsText()
- content += tb.Text
- case "tool_use":
- tu := block.AsToolUse()
- var args map[string]interface{}
- if err := json.Unmarshal(tu.Input, &args); err != nil {
- args = map[string]interface{}{"raw": string(tu.Input)}
- }
- toolCalls = append(toolCalls, ToolCall{
- ID: tu.ID,
- Name: tu.Name,
- Arguments: args,
- })
- }
- }
-
- finishReason := "stop"
- switch resp.StopReason {
- case anthropic.StopReasonToolUse:
- finishReason = "tool_calls"
- case anthropic.StopReasonMaxTokens:
- finishReason = "length"
- case anthropic.StopReasonEndTurn:
- finishReason = "stop"
- }
-
- return &LLMResponse{
- Content: content,
- ToolCalls: toolCalls,
- FinishReason: finishReason,
- Usage: &UsageInfo{
- PromptTokens: int(resp.Usage.InputTokens),
- CompletionTokens: int(resp.Usage.OutputTokens),
- TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens),
- },
- }
+ return p.delegate.GetDefaultModel()
}
func createClaudeTokenSource() func() (string, error) {
return func() (string, error) {
- cred, err := auth.GetCredential("anthropic")
+ cred, err := getCredential("anthropic")
if err != nil {
return "", fmt.Errorf("loading auth credentials: %w", err)
}
diff --git a/pkg/providers/claude_provider_test.go b/pkg/providers/claude_provider_test.go
index bbad2d269..b1bcd8b40 100644
--- a/pkg/providers/claude_provider_test.go
+++ b/pkg/providers/claude_provider_test.go
@@ -8,140 +8,9 @@ import (
"github.com/anthropics/anthropic-sdk-go"
anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
+ anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic"
)
-func TestBuildClaudeParams_BasicMessage(t *testing.T) {
- messages := []Message{
- {Role: "user", Content: "Hello"},
- }
- params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{
- "max_tokens": 1024,
- })
- if err != nil {
- t.Fatalf("buildClaudeParams() error: %v", err)
- }
- if string(params.Model) != "claude-sonnet-4-5-20250929" {
- t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-5-20250929")
- }
- if params.MaxTokens != 1024 {
- t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens)
- }
- if len(params.Messages) != 1 {
- t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
- }
-}
-
-func TestBuildClaudeParams_SystemMessage(t *testing.T) {
- messages := []Message{
- {Role: "system", Content: "You are helpful"},
- {Role: "user", Content: "Hi"},
- }
- params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
- if err != nil {
- t.Fatalf("buildClaudeParams() error: %v", err)
- }
- if len(params.System) != 1 {
- t.Fatalf("len(System) = %d, want 1", len(params.System))
- }
- if params.System[0].Text != "You are helpful" {
- t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful")
- }
- if len(params.Messages) != 1 {
- t.Fatalf("len(Messages) = %d, want 1", len(params.Messages))
- }
-}
-
-func TestBuildClaudeParams_ToolCallMessage(t *testing.T) {
- messages := []Message{
- {Role: "user", Content: "What's the weather?"},
- {
- Role: "assistant",
- Content: "",
- ToolCalls: []ToolCall{
- {
- ID: "call_1",
- Name: "get_weather",
- Arguments: map[string]interface{}{"city": "SF"},
- },
- },
- },
- {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
- }
- params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{})
- if err != nil {
- t.Fatalf("buildClaudeParams() error: %v", err)
- }
- if len(params.Messages) != 3 {
- t.Fatalf("len(Messages) = %d, want 3", len(params.Messages))
- }
-}
-
-func TestBuildClaudeParams_WithTools(t *testing.T) {
- tools := []ToolDefinition{
- {
- Type: "function",
- Function: ToolFunctionDefinition{
- Name: "get_weather",
- Description: "Get weather for a city",
- Parameters: map[string]interface{}{
- "type": "object",
- "properties": map[string]interface{}{
- "city": map[string]interface{}{"type": "string"},
- },
- "required": []interface{}{"city"},
- },
- },
- },
- }
- params, err := buildClaudeParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{})
- if err != nil {
- t.Fatalf("buildClaudeParams() error: %v", err)
- }
- if len(params.Tools) != 1 {
- t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
- }
-}
-
-func TestParseClaudeResponse_TextOnly(t *testing.T) {
- resp := &anthropic.Message{
- Content: []anthropic.ContentBlockUnion{},
- Usage: anthropic.Usage{
- InputTokens: 10,
- OutputTokens: 20,
- },
- }
- result := parseClaudeResponse(resp)
- if result.Usage.PromptTokens != 10 {
- t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens)
- }
- if result.Usage.CompletionTokens != 20 {
- t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens)
- }
- if result.FinishReason != "stop" {
- t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop")
- }
-}
-
-func TestParseClaudeResponse_StopReasons(t *testing.T) {
- tests := []struct {
- stopReason anthropic.StopReason
- want string
- }{
- {anthropic.StopReasonEndTurn, "stop"},
- {anthropic.StopReasonMaxTokens, "length"},
- {anthropic.StopReasonToolUse, "tool_calls"},
- }
- for _, tt := range tests {
- resp := &anthropic.Message{
- StopReason: tt.stopReason,
- }
- result := parseClaudeResponse(resp)
- if result.FinishReason != tt.want {
- t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want)
- }
- }
-}
-
func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/messages" {
@@ -175,11 +44,11 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
}))
defer server.Close()
- provider := NewClaudeProvider("test-token")
- provider.client = createAnthropicTestClient(server.URL, "test-token")
+ delegate := anthropicprovider.NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token"))
+ provider := newClaudeProviderWithDelegate(delegate)
messages := []Message{{Role: "user", Content: "Hello"}}
- resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024})
+ resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4.6", map[string]interface{}{"max_tokens": 1024})
if err != nil {
t.Fatalf("Chat() error: %v", err)
}
@@ -196,8 +65,8 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) {
func TestClaudeProvider_GetDefaultModel(t *testing.T) {
p := NewClaudeProvider("test-token")
- if got := p.GetDefaultModel(); got != "claude-sonnet-4-5-20250929" {
- t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4-5-20250929")
+ if got := p.GetDefaultModel(); got != "claude-sonnet-4.6" {
+ t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4.6")
}
}
diff --git a/pkg/providers/codex_cli_credentials.go b/pkg/providers/codex_cli_credentials.go
new file mode 100644
index 000000000..7ad39ce8e
--- /dev/null
+++ b/pkg/providers/codex_cli_credentials.go
@@ -0,0 +1,79 @@
+package providers
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// CodexCliAuth represents the ~/.codex/auth.json file structure.
+type CodexCliAuth struct {
+ Tokens struct {
+ AccessToken string `json:"access_token"`
+ RefreshToken string `json:"refresh_token"`
+ AccountID string `json:"account_id"`
+ } `json:"tokens"`
+}
+
+// ReadCodexCliCredentials reads OAuth tokens from the Codex CLI's auth.json file.
+// Expiry is estimated as file modification time + 1 hour (same approach as moltbot).
+func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Time, err error) {
+ authPath, err := resolveCodexAuthPath()
+ if err != nil {
+ return "", "", time.Time{}, err
+ }
+
+ data, err := os.ReadFile(authPath)
+ if err != nil {
+ return "", "", time.Time{}, fmt.Errorf("reading %s: %w", authPath, err)
+ }
+
+ var auth CodexCliAuth
+ if err := json.Unmarshal(data, &auth); err != nil {
+ return "", "", time.Time{}, fmt.Errorf("parsing %s: %w", authPath, err)
+ }
+
+ if auth.Tokens.AccessToken == "" {
+ return "", "", time.Time{}, fmt.Errorf("no access_token in %s", authPath)
+ }
+
+ stat, err := os.Stat(authPath)
+ if err != nil {
+ expiresAt = time.Now().Add(time.Hour)
+ } else {
+ expiresAt = stat.ModTime().Add(time.Hour)
+ }
+
+ return auth.Tokens.AccessToken, auth.Tokens.AccountID, expiresAt, nil
+}
+
+// CreateCodexCliTokenSource creates a token source that reads from ~/.codex/auth.json.
+// This allows the existing CodexProvider to reuse Codex CLI credentials.
+func CreateCodexCliTokenSource() func() (string, string, error) {
+ return func() (string, string, error) {
+ token, accountID, expiresAt, err := ReadCodexCliCredentials()
+ if err != nil {
+ return "", "", fmt.Errorf("reading codex cli credentials: %w", err)
+ }
+
+ if time.Now().After(expiresAt) {
+ return "", "", fmt.Errorf("codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login")
+ }
+
+ return token, accountID, nil
+ }
+}
+
+func resolveCodexAuthPath() (string, error) {
+ codexHome := os.Getenv("CODEX_HOME")
+ if codexHome == "" {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("getting home dir: %w", err)
+ }
+ codexHome = filepath.Join(home, ".codex")
+ }
+ return filepath.Join(codexHome, "auth.json"), nil
+}
diff --git a/pkg/providers/codex_cli_credentials_test.go b/pkg/providers/codex_cli_credentials_test.go
new file mode 100644
index 000000000..3267f2d16
--- /dev/null
+++ b/pkg/providers/codex_cli_credentials_test.go
@@ -0,0 +1,181 @@
+package providers
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestReadCodexCliCredentials_Valid(t *testing.T) {
+ tmpDir := t.TempDir()
+ authPath := filepath.Join(tmpDir, "auth.json")
+
+ authJSON := `{
+ "tokens": {
+ "access_token": "test-access-token",
+ "refresh_token": "test-refresh-token",
+ "account_id": "org-test123"
+ }
+ }`
+ if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("CODEX_HOME", tmpDir)
+
+ token, accountID, expiresAt, err := ReadCodexCliCredentials()
+ if err != nil {
+ t.Fatalf("ReadCodexCliCredentials() error: %v", err)
+ }
+ if token != "test-access-token" {
+ t.Errorf("token = %q, want %q", token, "test-access-token")
+ }
+ if accountID != "org-test123" {
+ t.Errorf("accountID = %q, want %q", accountID, "org-test123")
+ }
+ // Expiry should be within ~1 hour from now (file was just written)
+ if expiresAt.Before(time.Now()) {
+ t.Errorf("expiresAt = %v, should be in the future", expiresAt)
+ }
+ if expiresAt.After(time.Now().Add(2 * time.Hour)) {
+ t.Errorf("expiresAt = %v, should be within ~1 hour", expiresAt)
+ }
+}
+
+func TestReadCodexCliCredentials_MissingFile(t *testing.T) {
+ tmpDir := t.TempDir()
+ t.Setenv("CODEX_HOME", tmpDir)
+
+ _, _, _, err := ReadCodexCliCredentials()
+ if err == nil {
+ t.Fatal("expected error for missing auth.json")
+ }
+}
+
+func TestReadCodexCliCredentials_EmptyToken(t *testing.T) {
+ tmpDir := t.TempDir()
+ authPath := filepath.Join(tmpDir, "auth.json")
+
+ authJSON := `{"tokens": {"access_token": "", "refresh_token": "r", "account_id": "a"}}`
+ if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("CODEX_HOME", tmpDir)
+
+ _, _, _, err := ReadCodexCliCredentials()
+ if err == nil {
+ t.Fatal("expected error for empty access_token")
+ }
+}
+
+func TestReadCodexCliCredentials_InvalidJSON(t *testing.T) {
+ tmpDir := t.TempDir()
+ authPath := filepath.Join(tmpDir, "auth.json")
+
+ if err := os.WriteFile(authPath, []byte("not json"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("CODEX_HOME", tmpDir)
+
+ _, _, _, err := ReadCodexCliCredentials()
+ if err == nil {
+ t.Fatal("expected error for invalid JSON")
+ }
+}
+
+func TestReadCodexCliCredentials_NoAccountID(t *testing.T) {
+ tmpDir := t.TempDir()
+ authPath := filepath.Join(tmpDir, "auth.json")
+
+ authJSON := `{"tokens": {"access_token": "tok123", "refresh_token": "ref456"}}`
+ if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("CODEX_HOME", tmpDir)
+
+ token, accountID, _, err := ReadCodexCliCredentials()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if token != "tok123" {
+ t.Errorf("token = %q, want %q", token, "tok123")
+ }
+ if accountID != "" {
+ t.Errorf("accountID = %q, want empty", accountID)
+ }
+}
+
+func TestReadCodexCliCredentials_CodexHomeEnv(t *testing.T) {
+ tmpDir := t.TempDir()
+ customDir := filepath.Join(tmpDir, "custom-codex")
+ if err := os.MkdirAll(customDir, 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ authJSON := `{"tokens": {"access_token": "custom-token", "refresh_token": "r"}}`
+ if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("CODEX_HOME", customDir)
+
+ token, _, _, err := ReadCodexCliCredentials()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if token != "custom-token" {
+ t.Errorf("token = %q, want %q", token, "custom-token")
+ }
+}
+
+func TestCreateCodexCliTokenSource_Valid(t *testing.T) {
+ tmpDir := t.TempDir()
+ authPath := filepath.Join(tmpDir, "auth.json")
+
+ authJSON := `{"tokens": {"access_token": "fresh-token", "refresh_token": "r", "account_id": "acc"}}`
+ if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("CODEX_HOME", tmpDir)
+
+ source := CreateCodexCliTokenSource()
+ token, accountID, err := source()
+ if err != nil {
+ t.Fatalf("token source error: %v", err)
+ }
+ if token != "fresh-token" {
+ t.Errorf("token = %q, want %q", token, "fresh-token")
+ }
+ if accountID != "acc" {
+ t.Errorf("accountID = %q, want %q", accountID, "acc")
+ }
+}
+
+func TestCreateCodexCliTokenSource_Expired(t *testing.T) {
+ tmpDir := t.TempDir()
+ authPath := filepath.Join(tmpDir, "auth.json")
+
+ authJSON := `{"tokens": {"access_token": "old-token", "refresh_token": "r"}}`
+ if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ // Set file modification time to 2 hours ago
+ oldTime := time.Now().Add(-2 * time.Hour)
+ if err := os.Chtimes(authPath, oldTime, oldTime); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("CODEX_HOME", tmpDir)
+
+ source := CreateCodexCliTokenSource()
+ _, _, err := source()
+ if err == nil {
+ t.Fatal("expected error for expired credentials")
+ }
+}
diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go
new file mode 100644
index 000000000..8886406b4
--- /dev/null
+++ b/pkg/providers/codex_cli_provider.go
@@ -0,0 +1,251 @@
+package providers
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "os/exec"
+ "strings"
+)
+
+// CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess.
+type CodexCliProvider struct {
+ command string
+ workspace string
+}
+
+// NewCodexCliProvider creates a new Codex CLI provider.
+func NewCodexCliProvider(workspace string) *CodexCliProvider {
+ return &CodexCliProvider{
+ command: "codex",
+ workspace: workspace,
+ }
+}
+
+// Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode.
+func (p *CodexCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+ if p.command == "" {
+ return nil, fmt.Errorf("codex command not configured")
+ }
+
+ prompt := p.buildPrompt(messages, tools)
+
+ args := []string{
+ "exec",
+ "--json",
+ "--dangerously-bypass-approvals-and-sandbox",
+ "--skip-git-repo-check",
+ "--color", "never",
+ }
+ if model != "" && model != "codex-cli" {
+ args = append(args, "-m", model)
+ }
+ if p.workspace != "" {
+ args = append(args, "-C", p.workspace)
+ }
+ args = append(args, "-") // read prompt from stdin
+
+ cmd := exec.CommandContext(ctx, p.command, args...)
+ cmd.Stdin = bytes.NewReader([]byte(prompt))
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ err := cmd.Run()
+
+ // Parse JSONL from stdout even if exit code is non-zero,
+ // because codex writes diagnostic noise to stderr (e.g. rollout errors)
+ // but still produces valid JSONL output.
+ if stdoutStr := stdout.String(); stdoutStr != "" {
+ resp, parseErr := p.parseJSONLEvents(stdoutStr)
+ if parseErr == nil && resp != nil && (resp.Content != "" || len(resp.ToolCalls) > 0) {
+ return resp, nil
+ }
+ }
+
+ if err != nil {
+ if ctx.Err() == context.Canceled {
+ return nil, ctx.Err()
+ }
+ if stderrStr := stderr.String(); stderrStr != "" {
+ return nil, fmt.Errorf("codex cli error: %s", stderrStr)
+ }
+ return nil, fmt.Errorf("codex cli error: %w", err)
+ }
+
+ return p.parseJSONLEvents(stdout.String())
+}
+
+// GetDefaultModel returns the default model identifier.
+func (p *CodexCliProvider) GetDefaultModel() string {
+ return "codex-cli"
+}
+
+// buildPrompt converts messages to a prompt string for the Codex CLI.
+// System messages are prepended as instructions since Codex CLI has no --system-prompt flag.
+func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinition) string {
+ var systemParts []string
+ var conversationParts []string
+
+ for _, msg := range messages {
+ switch msg.Role {
+ case "system":
+ systemParts = append(systemParts, msg.Content)
+ case "user":
+ conversationParts = append(conversationParts, msg.Content)
+ case "assistant":
+ conversationParts = append(conversationParts, "Assistant: "+msg.Content)
+ case "tool":
+ conversationParts = append(conversationParts,
+ fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content))
+ }
+ }
+
+ var sb strings.Builder
+
+ if len(systemParts) > 0 {
+ sb.WriteString("## System Instructions\n\n")
+ sb.WriteString(strings.Join(systemParts, "\n\n"))
+ sb.WriteString("\n\n## Task\n\n")
+ }
+
+ if len(tools) > 0 {
+ sb.WriteString(p.buildToolsPrompt(tools))
+ sb.WriteString("\n\n")
+ }
+
+ // Simplify single user message (no prefix)
+ if len(conversationParts) == 1 && len(systemParts) == 0 && len(tools) == 0 {
+ return conversationParts[0]
+ }
+
+ sb.WriteString(strings.Join(conversationParts, "\n"))
+ return sb.String()
+}
+
+// buildToolsPrompt creates a tool definitions section for the prompt.
+func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
+ var sb strings.Builder
+
+ sb.WriteString("## Available Tools\n\n")
+ sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n")
+ sb.WriteString("```json\n")
+ sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`)
+ sb.WriteString("\n```\n\n")
+ sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n")
+ sb.WriteString("### Tool Definitions:\n\n")
+
+ for _, tool := range tools {
+ if tool.Type != "function" {
+ continue
+ }
+ sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name))
+ if tool.Function.Description != "" {
+ sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description))
+ }
+ if len(tool.Function.Parameters) > 0 {
+ paramsJSON, _ := json.Marshal(tool.Function.Parameters)
+ sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON)))
+ }
+ sb.WriteString("\n")
+ }
+
+ return sb.String()
+}
+
+// codexEvent represents a single JSONL event from `codex exec --json`.
+type codexEvent struct {
+ Type string `json:"type"`
+ ThreadID string `json:"thread_id,omitempty"`
+ Message string `json:"message,omitempty"`
+ Item *codexEventItem `json:"item,omitempty"`
+ Usage *codexUsage `json:"usage,omitempty"`
+ Error *codexEventErr `json:"error,omitempty"`
+}
+
+type codexEventItem struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Text string `json:"text,omitempty"`
+ Command string `json:"command,omitempty"`
+ Status string `json:"status,omitempty"`
+ ExitCode *int `json:"exit_code,omitempty"`
+ Output string `json:"output,omitempty"`
+}
+
+type codexUsage struct {
+ InputTokens int `json:"input_tokens"`
+ CachedInputTokens int `json:"cached_input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+}
+
+type codexEventErr struct {
+ Message string `json:"message"`
+}
+
+// parseJSONLEvents processes the JSONL output from codex exec --json.
+func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) {
+ var contentParts []string
+ var usage *UsageInfo
+ var lastError string
+
+ scanner := bufio.NewScanner(strings.NewReader(output))
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ if line == "" {
+ continue
+ }
+
+ var event codexEvent
+ if err := json.Unmarshal([]byte(line), &event); err != nil {
+ continue // skip malformed lines
+ }
+
+ switch event.Type {
+ case "item.completed":
+ if event.Item != nil && event.Item.Type == "agent_message" && event.Item.Text != "" {
+ contentParts = append(contentParts, event.Item.Text)
+ }
+ case "turn.completed":
+ if event.Usage != nil {
+ promptTokens := event.Usage.InputTokens + event.Usage.CachedInputTokens
+ usage = &UsageInfo{
+ PromptTokens: promptTokens,
+ CompletionTokens: event.Usage.OutputTokens,
+ TotalTokens: promptTokens + event.Usage.OutputTokens,
+ }
+ }
+ case "error":
+ lastError = event.Message
+ case "turn.failed":
+ if event.Error != nil {
+ lastError = event.Error.Message
+ }
+ }
+ }
+
+ if lastError != "" && len(contentParts) == 0 {
+ return nil, fmt.Errorf("codex cli: %s", lastError)
+ }
+
+ content := strings.Join(contentParts, "\n")
+
+ // Extract tool calls from response text (same pattern as ClaudeCliProvider)
+ toolCalls := extractToolCallsFromText(content)
+
+ finishReason := "stop"
+ if len(toolCalls) > 0 {
+ finishReason = "tool_calls"
+ content = stripToolCallsFromText(content)
+ }
+
+ return &LLMResponse{
+ Content: strings.TrimSpace(content),
+ ToolCalls: toolCalls,
+ FinishReason: finishReason,
+ Usage: usage,
+ }, nil
+}
diff --git a/pkg/providers/codex_cli_provider_integration_test.go b/pkg/providers/codex_cli_provider_integration_test.go
new file mode 100644
index 000000000..0267c730f
--- /dev/null
+++ b/pkg/providers/codex_cli_provider_integration_test.go
@@ -0,0 +1,119 @@
+//go:build integration
+
+package providers
+
+import (
+ "context"
+ exec "os/exec"
+ "strings"
+ "testing"
+ "time"
+)
+
+// TestIntegration_RealCodexCLI tests the CodexCliProvider with a real codex CLI.
+// Run with: go test -tags=integration ./pkg/providers/...
+func TestIntegration_RealCodexCLI(t *testing.T) {
+ path, err := exec.LookPath("codex")
+ if err != nil {
+ t.Skip("codex CLI not found in PATH, skipping integration test")
+ }
+ t.Logf("Using codex CLI at: %s", path)
+
+ p := NewCodexCliProvider(t.TempDir())
+
+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
+ defer cancel()
+
+ resp, err := p.Chat(ctx, []Message{
+ {Role: "user", Content: "Respond with only the word 'pong'. Nothing else."},
+ }, nil, "", nil)
+
+ if err != nil {
+ t.Fatalf("Chat() with real CLI error = %v", err)
+ }
+
+ if resp.Content == "" {
+ t.Error("Content is empty")
+ }
+ if resp.FinishReason != "stop" {
+ t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
+ }
+ if resp.Usage != nil {
+ t.Logf("Usage: prompt=%d, completion=%d, total=%d",
+ resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens)
+ }
+
+ t.Logf("Response content: %q", resp.Content)
+
+ if !strings.Contains(strings.ToLower(resp.Content), "pong") {
+ t.Errorf("Content = %q, expected to contain 'pong'", resp.Content)
+ }
+}
+
+func TestIntegration_RealCodexCLI_WithSystemPrompt(t *testing.T) {
+ if _, err := exec.LookPath("codex"); err != nil {
+ t.Skip("codex CLI not found in PATH")
+ }
+
+ p := NewCodexCliProvider(t.TempDir())
+
+ ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
+ defer cancel()
+
+ resp, err := p.Chat(ctx, []Message{
+ {Role: "system", Content: "You are a calculator. Only respond with numbers. No text."},
+ {Role: "user", Content: "What is 2+2?"},
+ }, nil, "", nil)
+
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+
+ t.Logf("Response: %q", resp.Content)
+
+ if !strings.Contains(resp.Content, "4") {
+ t.Errorf("Content = %q, expected to contain '4'", resp.Content)
+ }
+}
+
+func TestIntegration_RealCodexCLI_ParsesRealJSONL(t *testing.T) {
+ if _, err := exec.LookPath("codex"); err != nil {
+ t.Skip("codex CLI not found in PATH")
+ }
+
+ // Run codex directly and verify our parser handles real output
+ cmd := exec.Command("codex", "exec",
+ "--json",
+ "--dangerously-bypass-approvals-and-sandbox",
+ "--skip-git-repo-check",
+ "--color", "never",
+ "-C", t.TempDir(),
+ "-")
+ cmd.Stdin = strings.NewReader("Say hi")
+
+ output, err := cmd.Output()
+ if err != nil {
+ // codex may write diagnostic noise to stderr but still produce valid output
+ if len(output) == 0 {
+ t.Fatalf("codex CLI failed: %v", err)
+ }
+ }
+
+ t.Logf("Raw CLI output (first 500 chars): %s", string(output[:min(len(output), 500)]))
+
+ // Verify our parser can handle real output
+ p := NewCodexCliProvider("")
+ resp, err := p.parseJSONLEvents(string(output))
+ if err != nil {
+ t.Fatalf("parseJSONLEvents() failed on real CLI output: %v", err)
+ }
+
+ if resp.Content == "" {
+ t.Error("parsed Content is empty")
+ }
+ if resp.FinishReason != "stop" {
+ t.Errorf("FinishReason = %q, want stop", resp.FinishReason)
+ }
+
+ t.Logf("Parsed: content=%q, finish=%s, usage=%+v", resp.Content, resp.FinishReason, resp.Usage)
+}
diff --git a/pkg/providers/codex_cli_provider_test.go b/pkg/providers/codex_cli_provider_test.go
new file mode 100644
index 000000000..7e4e1bc15
--- /dev/null
+++ b/pkg/providers/codex_cli_provider_test.go
@@ -0,0 +1,585 @@
+package providers
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// --- JSONL Event Parsing Tests ---
+
+func TestParseJSONLEvents_AgentMessage(t *testing.T) {
+ p := &CodexCliProvider{}
+ events := `{"type":"thread.started","thread_id":"abc-123"}
+{"type":"turn.started"}
+{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Hello from Codex!"}}
+{"type":"turn.completed","usage":{"input_tokens":100,"cached_input_tokens":50,"output_tokens":20}}`
+
+ resp, err := p.parseJSONLEvents(events)
+ if err != nil {
+ t.Fatalf("parseJSONLEvents() error: %v", err)
+ }
+ if resp.Content != "Hello from Codex!" {
+ t.Errorf("Content = %q, want %q", resp.Content, "Hello from Codex!")
+ }
+ if resp.FinishReason != "stop" {
+ t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
+ }
+ if resp.Usage == nil {
+ t.Fatal("Usage should not be nil")
+ }
+ if resp.Usage.PromptTokens != 150 {
+ t.Errorf("PromptTokens = %d, want 150", resp.Usage.PromptTokens)
+ }
+ if resp.Usage.CompletionTokens != 20 {
+ t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens)
+ }
+ if resp.Usage.TotalTokens != 170 {
+ t.Errorf("TotalTokens = %d, want 170", resp.Usage.TotalTokens)
+ }
+ if len(resp.ToolCalls) != 0 {
+ t.Errorf("ToolCalls should be empty, got %d", len(resp.ToolCalls))
+ }
+}
+
+func TestParseJSONLEvents_ToolCallExtraction(t *testing.T) {
+ p := &CodexCliProvider{}
+ toolCallText := `Let me read that file.
+{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test.txt\"}"}}]}`
+ // Build valid JSONL by marshaling the event
+ item := codexEvent{
+ Type: "item.completed",
+ Item: &codexEventItem{ID: "item_1", Type: "agent_message", Text: toolCallText},
+ }
+ itemJSON, _ := json.Marshal(item)
+ usageEvt := `{"type":"turn.completed","usage":{"input_tokens":50,"cached_input_tokens":0,"output_tokens":20}}`
+ events := `{"type":"turn.started"}` + "\n" + string(itemJSON) + "\n" + usageEvt
+
+ resp, err := p.parseJSONLEvents(events)
+ if err != nil {
+ t.Fatalf("parseJSONLEvents() error: %v", err)
+ }
+ if resp.FinishReason != "tool_calls" {
+ t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
+ }
+ if len(resp.ToolCalls) != 1 {
+ t.Fatalf("ToolCalls count = %d, want 1", len(resp.ToolCalls))
+ }
+ if resp.ToolCalls[0].Name != "read_file" {
+ t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "read_file")
+ }
+ if resp.ToolCalls[0].ID != "call_1" {
+ t.Errorf("ToolCalls[0].ID = %q, want %q", resp.ToolCalls[0].ID, "call_1")
+ }
+ if resp.ToolCalls[0].Function.Arguments != `{"path":"/tmp/test.txt"}` {
+ t.Errorf("ToolCalls[0].Function.Arguments = %q", resp.ToolCalls[0].Function.Arguments)
+ }
+ // Content should have the tool call JSON stripped
+ if strings.Contains(resp.Content, "tool_calls") {
+ t.Errorf("Content should not contain tool_calls JSON, got: %q", resp.Content)
+ }
+}
+
+func TestParseJSONLEvents_MultipleToolCalls(t *testing.T) {
+ p := &CodexCliProvider{}
+ toolCallText := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"a.txt\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"b.txt\",\"content\":\"hello\"}"}}]}`
+ item := codexEvent{
+ Type: "item.completed",
+ Item: &codexEventItem{ID: "item_1", Type: "agent_message", Text: toolCallText},
+ }
+ itemJSON, _ := json.Marshal(item)
+ events := `{"type":"turn.started"}` + "\n" + string(itemJSON) + "\n" + `{"type":"turn.completed"}`
+
+ resp, err := p.parseJSONLEvents(events)
+ if err != nil {
+ t.Fatalf("parseJSONLEvents() error: %v", err)
+ }
+ if len(resp.ToolCalls) != 2 {
+ t.Fatalf("ToolCalls count = %d, want 2", len(resp.ToolCalls))
+ }
+ if resp.ToolCalls[0].Name != "read_file" {
+ t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "read_file")
+ }
+ if resp.ToolCalls[1].Name != "write_file" {
+ t.Errorf("ToolCalls[1].Name = %q, want %q", resp.ToolCalls[1].Name, "write_file")
+ }
+ if resp.FinishReason != "tool_calls" {
+ t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls")
+ }
+}
+
+func TestParseJSONLEvents_MultipleMessages(t *testing.T) {
+ p := &CodexCliProvider{}
+ events := `{"type":"turn.started"}
+{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"First part."}}
+{"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"ls","status":"completed"}}
+{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Second part."}}
+{"type":"turn.completed"}`
+
+ resp, err := p.parseJSONLEvents(events)
+ if err != nil {
+ t.Fatalf("parseJSONLEvents() error: %v", err)
+ }
+ if resp.Content != "First part.\nSecond part." {
+ t.Errorf("Content = %q, want %q", resp.Content, "First part.\nSecond part.")
+ }
+}
+
+func TestParseJSONLEvents_ErrorEvent(t *testing.T) {
+ p := &CodexCliProvider{}
+ events := `{"type":"thread.started","thread_id":"abc"}
+{"type":"turn.started"}
+{"type":"error","message":"token expired"}
+{"type":"turn.failed","error":{"message":"token expired"}}`
+
+ _, err := p.parseJSONLEvents(events)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(err.Error(), "token expired") {
+ t.Errorf("error = %q, want to contain 'token expired'", err.Error())
+ }
+}
+
+func TestParseJSONLEvents_TurnFailed(t *testing.T) {
+ p := &CodexCliProvider{}
+ events := `{"type":"turn.started"}
+{"type":"turn.failed","error":{"message":"rate limit exceeded"}}`
+
+ _, err := p.parseJSONLEvents(events)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(err.Error(), "rate limit exceeded") {
+ t.Errorf("error = %q, want to contain 'rate limit exceeded'", err.Error())
+ }
+}
+
+func TestParseJSONLEvents_ErrorWithContent(t *testing.T) {
+ p := &CodexCliProvider{}
+ // If there's an error but also content, return the content (partial success)
+ events := `{"type":"turn.started"}
+{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Partial result."}}
+{"type":"error","message":"connection reset"}
+{"type":"turn.failed","error":{"message":"connection reset"}}`
+
+ resp, err := p.parseJSONLEvents(events)
+ if err != nil {
+ t.Fatalf("should not error when content exists: %v", err)
+ }
+ if resp.Content != "Partial result." {
+ t.Errorf("Content = %q, want %q", resp.Content, "Partial result.")
+ }
+}
+
+func TestParseJSONLEvents_EmptyOutput(t *testing.T) {
+ p := &CodexCliProvider{}
+ resp, err := p.parseJSONLEvents("")
+ if err != nil {
+ t.Fatalf("empty output should not error: %v", err)
+ }
+ if resp.Content != "" {
+ t.Errorf("Content = %q, want empty", resp.Content)
+ }
+}
+
+func TestParseJSONLEvents_MalformedLines(t *testing.T) {
+ p := &CodexCliProvider{}
+ events := `not json at all
+{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Good line."}}
+another bad line
+{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5}}`
+
+ resp, err := p.parseJSONLEvents(events)
+ if err != nil {
+ t.Fatalf("should skip malformed lines: %v", err)
+ }
+ if resp.Content != "Good line." {
+ t.Errorf("Content = %q, want %q", resp.Content, "Good line.")
+ }
+ if resp.Usage == nil || resp.Usage.TotalTokens != 15 {
+ t.Errorf("Usage.TotalTokens = %v, want 15", resp.Usage)
+ }
+}
+
+func TestParseJSONLEvents_CommandExecution(t *testing.T) {
+ p := &CodexCliProvider{}
+ events := `{"type":"turn.started"}
+{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"in_progress"}}
+{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"completed","exit_code":0,"output":"file1.go\nfile2.go"}}
+{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"Found 2 files."}}
+{"type":"turn.completed"}`
+
+ resp, err := p.parseJSONLEvents(events)
+ if err != nil {
+ t.Fatalf("parseJSONLEvents() error: %v", err)
+ }
+ // command_execution items should be skipped; only agent_message text is returned
+ if resp.Content != "Found 2 files." {
+ t.Errorf("Content = %q, want %q", resp.Content, "Found 2 files.")
+ }
+}
+
+func TestParseJSONLEvents_NoUsage(t *testing.T) {
+ p := &CodexCliProvider{}
+ events := `{"type":"turn.started"}
+{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"No usage info."}}
+{"type":"turn.completed"}`
+
+ resp, err := p.parseJSONLEvents(events)
+ if err != nil {
+ t.Fatalf("parseJSONLEvents() error: %v", err)
+ }
+ if resp.Usage != nil {
+ t.Errorf("Usage should be nil when turn.completed has no usage, got %+v", resp.Usage)
+ }
+}
+
+// --- Prompt Building Tests ---
+
+func TestBuildPrompt_SystemAsInstructions(t *testing.T) {
+ p := &CodexCliProvider{}
+ messages := []Message{
+ {Role: "system", Content: "You are helpful."},
+ {Role: "user", Content: "Hi there"},
+ }
+
+ prompt := p.buildPrompt(messages, nil)
+
+ if !strings.Contains(prompt, "## System Instructions") {
+ t.Error("prompt should contain '## System Instructions'")
+ }
+ if !strings.Contains(prompt, "You are helpful.") {
+ t.Error("prompt should contain system content")
+ }
+ if !strings.Contains(prompt, "## Task") {
+ t.Error("prompt should contain '## Task'")
+ }
+ if !strings.Contains(prompt, "Hi there") {
+ t.Error("prompt should contain user message")
+ }
+}
+
+func TestBuildPrompt_NoSystem(t *testing.T) {
+ p := &CodexCliProvider{}
+ messages := []Message{
+ {Role: "user", Content: "Just a question"},
+ }
+
+ prompt := p.buildPrompt(messages, nil)
+
+ if strings.Contains(prompt, "## System Instructions") {
+ t.Error("prompt should not contain system instructions header")
+ }
+ if prompt != "Just a question" {
+ t.Errorf("prompt = %q, want %q", prompt, "Just a question")
+ }
+}
+
+func TestBuildPrompt_WithTools(t *testing.T) {
+ p := &CodexCliProvider{}
+ messages := []Message{
+ {Role: "user", Content: "Get weather"},
+ }
+ tools := []ToolDefinition{
+ {
+ Type: "function",
+ Function: ToolFunctionDefinition{
+ Name: "get_weather",
+ Description: "Get current weather",
+ Parameters: map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "city": map[string]interface{}{"type": "string"},
+ },
+ },
+ },
+ },
+ }
+
+ prompt := p.buildPrompt(messages, tools)
+
+ if !strings.Contains(prompt, "## Available Tools") {
+ t.Error("prompt should contain tools section")
+ }
+ if !strings.Contains(prompt, "get_weather") {
+ t.Error("prompt should contain tool name")
+ }
+ if !strings.Contains(prompt, "Get current weather") {
+ t.Error("prompt should contain tool description")
+ }
+}
+
+func TestBuildPrompt_MultipleMessages(t *testing.T) {
+ p := &CodexCliProvider{}
+ messages := []Message{
+ {Role: "user", Content: "Hello"},
+ {Role: "assistant", Content: "Hi! How can I help?"},
+ {Role: "user", Content: "Tell me about Go"},
+ }
+
+ prompt := p.buildPrompt(messages, nil)
+
+ if !strings.Contains(prompt, "Hello") {
+ t.Error("prompt should contain first user message")
+ }
+ if !strings.Contains(prompt, "Assistant: Hi! How can I help?") {
+ t.Error("prompt should contain assistant message with prefix")
+ }
+ if !strings.Contains(prompt, "Tell me about Go") {
+ t.Error("prompt should contain second user message")
+ }
+}
+
+func TestBuildPrompt_ToolResults(t *testing.T) {
+ p := &CodexCliProvider{}
+ messages := []Message{
+ {Role: "user", Content: "Weather?"},
+ {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
+ }
+
+ prompt := p.buildPrompt(messages, nil)
+
+ if !strings.Contains(prompt, "[Tool Result for call_1]") {
+ t.Error("prompt should contain tool result")
+ }
+ if !strings.Contains(prompt, `{"temp": 72}`) {
+ t.Error("prompt should contain tool result content")
+ }
+}
+
+func TestBuildPrompt_SystemAndTools(t *testing.T) {
+ p := &CodexCliProvider{}
+ messages := []Message{
+ {Role: "system", Content: "Be concise."},
+ {Role: "user", Content: "Do something"},
+ }
+ tools := []ToolDefinition{
+ {
+ Type: "function",
+ Function: ToolFunctionDefinition{
+ Name: "my_tool",
+ Description: "A tool",
+ },
+ },
+ }
+
+ prompt := p.buildPrompt(messages, tools)
+
+ // System instructions should come first
+ sysIdx := strings.Index(prompt, "## System Instructions")
+ toolIdx := strings.Index(prompt, "## Available Tools")
+ taskIdx := strings.Index(prompt, "## Task")
+
+ if sysIdx == -1 || toolIdx == -1 || taskIdx == -1 {
+ t.Fatal("prompt should contain all sections")
+ }
+ if sysIdx >= taskIdx {
+ t.Error("system instructions should come before task")
+ }
+ if taskIdx >= toolIdx {
+ t.Error("task section should come before tools in the output")
+ }
+}
+
+// --- CLI Argument Tests ---
+
+func TestCodexCliProvider_GetDefaultModel(t *testing.T) {
+ p := NewCodexCliProvider("")
+ if got := p.GetDefaultModel(); got != "codex-cli" {
+ t.Errorf("GetDefaultModel() = %q, want %q", got, "codex-cli")
+ }
+}
+
+// --- Mock CLI Integration Test ---
+
+func createMockCodexCLI(t *testing.T, events []string) string {
+ t.Helper()
+ tmpDir := t.TempDir()
+ scriptPath := filepath.Join(tmpDir, "codex")
+
+ var sb strings.Builder
+ sb.WriteString("#!/bin/bash\n")
+ for _, event := range events {
+ sb.WriteString(fmt.Sprintf("echo '%s'\n", event))
+ }
+
+ if err := os.WriteFile(scriptPath, []byte(sb.String()), 0755); err != nil {
+ t.Fatal(err)
+ }
+ return scriptPath
+}
+
+func TestCodexCliProvider_MockCLI_Success(t *testing.T) {
+ scriptPath := createMockCodexCLI(t, []string{
+ `{"type":"thread.started","thread_id":"test-123"}`,
+ `{"type":"turn.started"}`,
+ `{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Mock response from Codex CLI"}}`,
+ `{"type":"turn.completed","usage":{"input_tokens":50,"cached_input_tokens":10,"output_tokens":15}}`,
+ })
+
+ p := &CodexCliProvider{
+ command: scriptPath,
+ workspace: "",
+ }
+
+ messages := []Message{{Role: "user", Content: "Hello"}}
+ resp, err := p.Chat(context.Background(), messages, nil, "", nil)
+ if err != nil {
+ t.Fatalf("Chat() error: %v", err)
+ }
+ if resp.Content != "Mock response from Codex CLI" {
+ t.Errorf("Content = %q, want %q", resp.Content, "Mock response from Codex CLI")
+ }
+ if resp.Usage == nil {
+ t.Fatal("Usage should not be nil")
+ }
+ if resp.Usage.PromptTokens != 60 {
+ t.Errorf("PromptTokens = %d, want 60", resp.Usage.PromptTokens)
+ }
+ if resp.Usage.CompletionTokens != 15 {
+ t.Errorf("CompletionTokens = %d, want 15", resp.Usage.CompletionTokens)
+ }
+}
+
+func TestCodexCliProvider_MockCLI_Error(t *testing.T) {
+ scriptPath := createMockCodexCLI(t, []string{
+ `{"type":"thread.started","thread_id":"test-err"}`,
+ `{"type":"turn.started"}`,
+ `{"type":"error","message":"auth token expired"}`,
+ `{"type":"turn.failed","error":{"message":"auth token expired"}}`,
+ })
+
+ p := &CodexCliProvider{
+ command: scriptPath,
+ workspace: "",
+ }
+
+ messages := []Message{{Role: "user", Content: "Hello"}}
+ _, err := p.Chat(context.Background(), messages, nil, "", nil)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !strings.Contains(err.Error(), "auth token expired") {
+ t.Errorf("error = %q, want to contain 'auth token expired'", err.Error())
+ }
+}
+
+func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) {
+ // Mock script that captures args to verify model flag is passed
+ tmpDir := t.TempDir()
+ scriptPath := filepath.Join(tmpDir, "codex")
+ script := `#!/bin/bash
+# Write args to a file for verification
+echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `"
+echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}'
+echo '{"type":"turn.completed"}'`
+
+ if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ p := &CodexCliProvider{
+ command: scriptPath,
+ workspace: "/tmp/test-workspace",
+ }
+
+ messages := []Message{{Role: "user", Content: "test"}}
+ _, err := p.Chat(context.Background(), messages, nil, "gpt-5.2-codex", nil)
+ if err != nil {
+ t.Fatalf("Chat() error: %v", err)
+ }
+
+ // Verify the args
+ argsData, err := os.ReadFile(filepath.Join(tmpDir, "args.txt"))
+ if err != nil {
+ t.Fatalf("reading args: %v", err)
+ }
+ args := string(argsData)
+
+ if !strings.Contains(args, "-m gpt-5.2-codex") {
+ t.Errorf("args should contain model flag, got: %s", args)
+ }
+ if !strings.Contains(args, "-C /tmp/test-workspace") {
+ t.Errorf("args should contain workspace flag, got: %s", args)
+ }
+ if !strings.Contains(args, "--json") {
+ t.Errorf("args should contain --json, got: %s", args)
+ }
+ if !strings.Contains(args, "--dangerously-bypass-approvals-and-sandbox") {
+ t.Errorf("args should contain bypass flag, got: %s", args)
+ }
+}
+
+func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) {
+ // Script that sleeps forever
+ tmpDir := t.TempDir()
+ scriptPath := filepath.Join(tmpDir, "codex")
+ script := "#!/bin/bash\nsleep 60"
+
+ if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
+ t.Fatal(err)
+ }
+
+ p := &CodexCliProvider{
+ command: scriptPath,
+ workspace: "",
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel() // cancel immediately
+
+ messages := []Message{{Role: "user", Content: "test"}}
+ _, err := p.Chat(ctx, messages, nil, "", nil)
+ if err == nil {
+ t.Fatal("expected error on canceled context")
+ }
+}
+
+func TestCodexCliProvider_EmptyCommand(t *testing.T) {
+ p := &CodexCliProvider{command: ""}
+
+ messages := []Message{{Role: "user", Content: "test"}}
+ _, err := p.Chat(context.Background(), messages, nil, "", nil)
+ if err == nil {
+ t.Fatal("expected error for empty command")
+ }
+}
+
+// --- Integration Test (requires real codex CLI with valid auth) ---
+
+func TestCodexCliProvider_Integration(t *testing.T) {
+ if os.Getenv("PICOCLAW_INTEGRATION_TESTS") == "" {
+ t.Skip("skipping integration test (set PICOCLAW_INTEGRATION_TESTS=1 to enable)")
+ }
+
+ // Verify codex is available
+ codexPath, err := exec.LookPath("codex")
+ if err != nil {
+ t.Skip("codex CLI not found in PATH")
+ }
+
+ p := &CodexCliProvider{
+ command: codexPath,
+ workspace: "",
+ }
+
+ messages := []Message{
+ {Role: "user", Content: "Respond with just the word 'hello' and nothing else."},
+ }
+
+ resp, err := p.Chat(context.Background(), messages, nil, "", nil)
+ if err != nil {
+ t.Fatalf("Chat() error: %v", err)
+ }
+
+ lower := strings.ToLower(strings.TrimSpace(resp.Content))
+ if !strings.Contains(lower, "hello") {
+ t.Errorf("Content = %q, expected to contain 'hello'", resp.Content)
+ }
+}
diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go
index c0b10bd5b..e3526cfb5 100644
--- a/pkg/providers/codex_provider.go
+++ b/pkg/providers/codex_provider.go
@@ -3,6 +3,7 @@ package providers
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"strings"
@@ -10,12 +11,17 @@ import (
"github.com/openai/openai-go/v3/option"
"github.com/openai/openai-go/v3/responses"
"github.com/sipeed/picoclaw/pkg/auth"
+ "github.com/sipeed/picoclaw/pkg/logger"
)
+const codexDefaultModel = "gpt-5.2"
+const codexDefaultInstructions = "You are Codex, a coding assistant."
+
type CodexProvider struct {
- client *openai.Client
- accountID string
- tokenSource func() (string, string, error)
+ client *openai.Client
+ accountID string
+ tokenSource func() (string, string, error)
+ enableWebSearch bool
}
const defaultCodexInstructions = "You are Codex, a coding assistant."
@@ -24,14 +30,17 @@ func NewCodexProvider(token, accountID string) *CodexProvider {
opts := []option.RequestOption{
option.WithBaseURL("https://chatgpt.com/backend-api/codex"),
option.WithAPIKey(token),
+ option.WithHeader("originator", "codex_cli_rs"),
+ option.WithHeader("OpenAI-Beta", "responses=experimental"),
}
if accountID != "" {
opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
}
client := openai.NewClient(opts...)
return &CodexProvider{
- client: &client,
- accountID: accountID,
+ client: &client,
+ accountID: accountID,
+ enableWebSearch: true,
}
}
@@ -43,6 +52,15 @@ func NewCodexProviderWithTokenSource(token, accountID string, tokenSource func()
func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
var opts []option.RequestOption
+ accountID := p.accountID
+ resolvedModel, fallbackReason := resolveCodexModel(model)
+ if fallbackReason != "" {
+ logger.WarnCF("provider.codex", "Requested model is not compatible with Codex backend, using fallback", map[string]interface{}{
+ "requested_model": model,
+ "resolved_model": resolvedModel,
+ "reason": fallbackReason,
+ })
+ }
if p.tokenSource != nil {
tok, accID, err := p.tokenSource()
if err != nil {
@@ -50,25 +68,123 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To
}
opts = append(opts, option.WithAPIKey(tok))
if accID != "" {
- opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accID))
+ accountID = accID
}
}
+ if accountID != "" {
+ opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID))
+ } else {
+ logger.WarnCF("provider.codex", "No account id found for Codex request; backend may reject with 400", map[string]interface{}{
+ "requested_model": model,
+ "resolved_model": resolvedModel,
+ })
+ }
- params := buildCodexParams(messages, tools, model, options)
+ params := buildCodexParams(messages, tools, resolvedModel, options, p.enableWebSearch)
- resp, err := p.client.Responses.New(ctx, params, opts...)
+ stream := p.client.Responses.NewStreaming(ctx, params, opts...)
+ defer stream.Close()
+
+ var resp *responses.Response
+ for stream.Next() {
+ evt := stream.Current()
+ if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" {
+ evtResp := evt.Response
+ if evtResp.ID != "" {
+ copy := evtResp
+ resp = ©
+ }
+ }
+ }
+ err := stream.Err()
if err != nil {
+ fields := map[string]interface{}{
+ "requested_model": model,
+ "resolved_model": resolvedModel,
+ "messages_count": len(messages),
+ "tools_count": len(tools),
+ "account_id_present": accountID != "",
+ "error": err.Error(),
+ }
+ var apiErr *openai.Error
+ if errors.As(err, &apiErr) {
+ fields["status_code"] = apiErr.StatusCode
+ fields["api_type"] = apiErr.Type
+ fields["api_code"] = apiErr.Code
+ fields["api_param"] = apiErr.Param
+ fields["api_message"] = apiErr.Message
+ if apiErr.StatusCode == 400 {
+ fields["hint"] = "verify account id header and model compatibility for codex backend"
+ }
+ if apiErr.Response != nil {
+ fields["request_id"] = apiErr.Response.Header.Get("x-request-id")
+ }
+ }
+ logger.ErrorCF("provider.codex", "Codex API call failed", fields)
return nil, fmt.Errorf("codex API call: %w", err)
}
+ if resp == nil {
+ fields := map[string]interface{}{
+ "requested_model": model,
+ "resolved_model": resolvedModel,
+ "messages_count": len(messages),
+ "tools_count": len(tools),
+ "account_id_present": accountID != "",
+ }
+ logger.ErrorCF("provider.codex", "Codex stream ended without completed response event", fields)
+ return nil, fmt.Errorf("codex API call: stream ended without completed response")
+ }
return parseCodexResponse(resp), nil
}
func (p *CodexProvider) GetDefaultModel() string {
- return "gpt-4o"
+ return codexDefaultModel
}
-func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) responses.ResponseNewParams {
+func resolveCodexModel(model string) (string, string) {
+ m := strings.ToLower(strings.TrimSpace(model))
+ if m == "" {
+ return codexDefaultModel, "empty model"
+ }
+
+ if strings.HasPrefix(m, "openai/") {
+ m = strings.TrimPrefix(m, "openai/")
+ } else if strings.Contains(m, "/") {
+ return codexDefaultModel, "non-openai model namespace"
+ }
+
+ unsupportedPrefixes := []string{
+ "glm",
+ "claude",
+ "anthropic",
+ "gemini",
+ "google",
+ "moonshot",
+ "kimi",
+ "qwen",
+ "deepseek",
+ "llama",
+ "meta-llama",
+ "mistral",
+ "grok",
+ "xai",
+ "zhipu",
+ }
+ for _, prefix := range unsupportedPrefixes {
+ if strings.HasPrefix(m, prefix) {
+ return codexDefaultModel, "unsupported model prefix"
+ }
+ }
+
+ if strings.HasPrefix(m, "gpt-") || strings.HasPrefix(m, "o3") || strings.HasPrefix(m, "o4") {
+ return m, ""
+ }
+
+ return codexDefaultModel, "unsupported model family"
+}
+
+func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}, enableWebSearch bool) responses.ResponseNewParams {
var inputItems responses.ResponseInputParam
var instructions string
@@ -103,12 +219,18 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
})
}
for _, tc := range msg.ToolCalls {
- argsJSON, _ := json.Marshal(tc.Arguments)
+ name, args, ok := resolveCodexToolCall(tc)
+ if !ok {
+ logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]interface{}{
+ "call_id": tc.ID,
+ })
+ continue
+ }
inputItems = append(inputItems, responses.ResponseInputItemUnionParam{
OfFunctionCall: &responses.ResponseFunctionToolCallParam{
CallID: tc.ID,
- Name: tc.Name,
- Arguments: string(argsJSON),
+ Name: name,
+ Arguments: args,
},
})
}
@@ -135,7 +257,8 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
Input: responses.ResponseNewParamsInputUnion{
OfInputItemList: inputItems,
},
- Store: openai.Opt(false),
+ Instructions: openai.Opt(instructions),
+ Store: openai.Opt(false),
}
if instructions != "" {
@@ -145,24 +268,50 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
params.Instructions = openai.Opt(defaultCodexInstructions)
}
- if maxTokens, ok := options["max_tokens"].(int); ok {
- params.MaxOutputTokens = openai.Opt(int64(maxTokens))
- }
-
- if temp, ok := options["temperature"].(float64); ok {
- params.Temperature = openai.Opt(temp)
- }
-
- if len(tools) > 0 {
- params.Tools = translateToolsForCodex(tools)
+ if len(tools) > 0 || enableWebSearch {
+ params.Tools = translateToolsForCodex(tools, enableWebSearch)
}
return params
}
-func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam {
- result := make([]responses.ToolUnionParam, 0, len(tools))
+func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool) {
+ name = tc.Name
+ if name == "" && tc.Function != nil {
+ name = tc.Function.Name
+ }
+ if name == "" {
+ return "", "", false
+ }
+
+ if len(tc.Arguments) > 0 {
+ argsJSON, err := json.Marshal(tc.Arguments)
+ if err != nil {
+ return "", "", false
+ }
+ return name, string(argsJSON), true
+ }
+
+ if tc.Function != nil && tc.Function.Arguments != "" {
+ return name, tc.Function.Arguments, true
+ }
+
+ return name, "{}", true
+}
+
+func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam {
+ capHint := len(tools)
+ if enableWebSearch {
+ capHint++
+ }
+ result := make([]responses.ToolUnionParam, 0, capHint)
for _, t := range tools {
+ if t.Type != "function" {
+ continue
+ }
+ if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") {
+ continue
+ }
ft := responses.FunctionToolParam{
Name: t.Function.Name,
Parameters: t.Function.Parameters,
@@ -173,6 +322,9 @@ func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam {
}
result = append(result, responses.ToolUnionParam{OfFunction: &ft})
}
+ if enableWebSearch {
+ result = append(result, responses.ToolParamOfWebSearch(responses.WebSearchToolTypeWebSearch))
+ }
return result
}
@@ -242,6 +394,9 @@ func createCodexTokenSource() func() (string, string, error) {
if err != nil {
return "", "", fmt.Errorf("refreshing token: %w", err)
}
+ if refreshed.AccountID == "" {
+ refreshed.AccountID = cred.AccountID
+ }
if err := auth.SetCredential("openai", refreshed); err != nil {
return "", "", fmt.Errorf("saving refreshed token: %w", err)
}
diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go
index 1a5a8cafa..92e276165 100644
--- a/pkg/providers/codex_provider_test.go
+++ b/pkg/providers/codex_provider_test.go
@@ -2,6 +2,7 @@ package providers
import (
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -16,8 +17,9 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) {
{Role: "user", Content: "Hello"},
}
params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{
- "max_tokens": 2048,
- })
+ "max_tokens": 2048,
+ "temperature": 0.7,
+ }, true)
if params.Model != "gpt-4o" {
t.Errorf("Model = %q, want %q", params.Model, "gpt-4o")
}
@@ -27,6 +29,9 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) {
if params.Instructions.Or("") != defaultCodexInstructions {
t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), defaultCodexInstructions)
}
+ if params.MaxOutputTokens.Valid() {
+ t.Fatalf("MaxOutputTokens should not be set for Codex backend")
+ }
}
func TestBuildCodexParams_SystemAsInstructions(t *testing.T) {
@@ -34,7 +39,7 @@ func TestBuildCodexParams_SystemAsInstructions(t *testing.T) {
{Role: "system", Content: "You are helpful"},
{Role: "user", Content: "Hi"},
}
- params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{})
+ params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, true)
if !params.Instructions.Valid() {
t.Fatal("Instructions should be set")
}
@@ -54,7 +59,7 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) {
},
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"},
}
- params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{})
+ params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false)
if params.Input.OfInputItemList == nil {
t.Fatal("Input.OfInputItemList should not be nil")
}
@@ -63,6 +68,45 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) {
}
}
+func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) {
+ messages := []Message{
+ {Role: "user", Content: "Read a file"},
+ {
+ Role: "assistant",
+ ToolCalls: []ToolCall{
+ {
+ ID: "call_1",
+ Type: "function",
+ Function: &FunctionCall{
+ Name: "read_file",
+ Arguments: `{"path":"README.md"}`,
+ },
+ },
+ },
+ },
+ {Role: "tool", Content: "ok", ToolCallID: "call_1"},
+ }
+
+ params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false)
+ if params.Input.OfInputItemList == nil {
+ t.Fatal("Input.OfInputItemList should not be nil")
+ }
+ if len(params.Input.OfInputItemList) != 3 {
+ t.Fatalf("len(Input items) = %d, want 3", len(params.Input.OfInputItemList))
+ }
+
+ fc := params.Input.OfInputItemList[1].OfFunctionCall
+ if fc == nil {
+ t.Fatal("assistant tool call should be converted to function_call input item")
+ }
+ if fc.Name != "read_file" {
+ t.Errorf("Function call name = %q, want %q", fc.Name, "read_file")
+ }
+ if fc.Arguments != `{"path":"README.md"}` {
+ t.Errorf("Function call arguments = %q, want %q", fc.Arguments, `{"path":"README.md"}`)
+ }
+}
+
func TestBuildCodexParams_WithTools(t *testing.T) {
tools := []ToolDefinition{
{
@@ -79,7 +123,7 @@ func TestBuildCodexParams_WithTools(t *testing.T) {
},
},
}
- params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{})
+ params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, false)
if len(params.Tools) != 1 {
t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
}
@@ -92,12 +136,61 @@ func TestBuildCodexParams_WithTools(t *testing.T) {
}
func TestBuildCodexParams_StoreIsFalse(t *testing.T) {
- params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{})
+ params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, false)
if !params.Store.Valid() || params.Store.Or(true) != false {
t.Error("Store should be explicitly set to false")
}
}
+func TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) {
+ params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, true)
+ if len(params.Tools) != 1 {
+ t.Fatalf("len(Tools) = %d, want 1", len(params.Tools))
+ }
+ if params.Tools[0].OfWebSearch == nil {
+ t.Fatal("Tool should include built-in web_search")
+ }
+ if params.Tools[0].OfWebSearch.Type != responses.WebSearchToolTypeWebSearch {
+ t.Errorf("Web search tool type = %q, want %q", params.Tools[0].OfWebSearch.Type, responses.WebSearchToolTypeWebSearch)
+ }
+}
+
+func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) {
+ tools := []ToolDefinition{
+ {
+ Type: "function",
+ Function: ToolFunctionDefinition{
+ Name: "web_search",
+ Description: "local web search",
+ Parameters: map[string]interface{}{
+ "type": "object",
+ },
+ },
+ },
+ {
+ Type: "function",
+ Function: ToolFunctionDefinition{
+ Name: "read_file",
+ Description: "read file",
+ Parameters: map[string]interface{}{
+ "type": "object",
+ },
+ },
+ },
+ }
+
+ params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, true)
+ if len(params.Tools) != 2 {
+ t.Fatalf("len(Tools) = %d, want 2", len(params.Tools))
+ }
+ if params.Tools[0].OfFunction == nil || params.Tools[0].OfFunction.Name != "read_file" {
+ t.Fatalf("first tool should be function read_file, got %#v", params.Tools[0])
+ }
+ if params.Tools[1].OfWebSearch == nil {
+ t.Fatalf("second tool should be built-in web_search, got %#v", params.Tools[1])
+ }
+}
+
func TestParseCodexResponse_TextOutput(t *testing.T) {
respJSON := `{
"id": "resp_test",
@@ -203,6 +296,30 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
return
}
+ var reqBody map[string]interface{}
+ if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ if reqBody["stream"] != true {
+ http.Error(w, "stream must be true", http.StatusBadRequest)
+ return
+ }
+ if _, ok := reqBody["max_output_tokens"]; ok {
+ http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest)
+ return
+ }
+ toolsAny, ok := reqBody["tools"].([]interface{})
+ if !ok || len(toolsAny) != 1 {
+ http.Error(w, "missing default web search tool", http.StatusBadRequest)
+ return
+ }
+ toolObj, ok := toolsAny[0].(map[string]interface{})
+ if !ok || toolObj["type"] != "web_search" {
+ http.Error(w, "expected web_search tool", http.StatusBadRequest)
+ return
+ }
+
resp := map[string]interface{}{
"id": "resp_test",
"object": "response",
@@ -226,8 +343,7 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
"output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
},
}
- w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(resp)
+ writeCompletedSSE(w, resp)
}))
defer server.Close()
@@ -250,10 +366,247 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) {
}
}
+func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/responses" {
+ http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
+ return
+ }
+
+ var reqBody map[string]interface{}
+ if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ if _, ok := reqBody["tools"]; ok {
+ http.Error(w, "tools should be absent when web search disabled", http.StatusBadRequest)
+ return
+ }
+
+ resp := map[string]interface{}{
+ "id": "resp_test",
+ "object": "response",
+ "status": "completed",
+ "output": []map[string]interface{}{
+ {
+ "id": "msg_1",
+ "type": "message",
+ "role": "assistant",
+ "status": "completed",
+ "content": []map[string]interface{}{
+ {"type": "output_text", "text": "Hi from Codex!"},
+ },
+ },
+ },
+ "usage": map[string]interface{}{
+ "input_tokens": 4,
+ "output_tokens": 3,
+ "total_tokens": 7,
+ "input_tokens_details": map[string]interface{}{"cached_tokens": 0},
+ "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
+ },
+ }
+ writeCompletedSSE(w, resp)
+ }))
+ defer server.Close()
+
+ provider := NewCodexProvider("test-token", "acc-123")
+ provider.enableWebSearch = false
+ provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
+
+ messages := []Message{{Role: "user", Content: "Hello"}}
+ resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{})
+ if err != nil {
+ t.Fatalf("Chat() error: %v", err)
+ }
+ if resp.Content != "Hi from Codex!" {
+ t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!")
+ }
+}
+
+func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/responses" {
+ http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
+ return
+ }
+ if r.Header.Get("Authorization") != "Bearer refreshed-token" {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ if r.Header.Get("Chatgpt-Account-Id") != "acc-123" {
+ http.Error(w, "missing account id", http.StatusBadRequest)
+ return
+ }
+
+ var reqBody map[string]interface{}
+ if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ if _, ok := reqBody["instructions"]; !ok {
+ http.Error(w, "missing instructions", http.StatusBadRequest)
+ return
+ }
+ if reqBody["instructions"] == "" {
+ http.Error(w, "instructions must not be empty", http.StatusBadRequest)
+ return
+ }
+ if _, ok := reqBody["temperature"]; ok {
+ http.Error(w, "temperature is not supported", http.StatusBadRequest)
+ return
+ }
+ if _, ok := reqBody["max_output_tokens"]; ok {
+ http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest)
+ return
+ }
+ if reqBody["stream"] != true {
+ http.Error(w, "stream must be true", http.StatusBadRequest)
+ return
+ }
+
+ resp := map[string]interface{}{
+ "id": "resp_test",
+ "object": "response",
+ "status": "completed",
+ "output": []map[string]interface{}{
+ {
+ "id": "msg_1",
+ "type": "message",
+ "role": "assistant",
+ "status": "completed",
+ "content": []map[string]interface{}{
+ {"type": "output_text", "text": "Hi from Codex!"},
+ },
+ },
+ },
+ "usage": map[string]interface{}{
+ "input_tokens": 8,
+ "output_tokens": 4,
+ "total_tokens": 12,
+ "input_tokens_details": map[string]interface{}{"cached_tokens": 0},
+ "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
+ },
+ }
+ writeCompletedSSE(w, resp)
+ }))
+ defer server.Close()
+
+ provider := NewCodexProvider("stale-token", "acc-123")
+ provider.client = createOpenAITestClient(server.URL, "stale-token", "")
+ provider.tokenSource = func() (string, string, error) {
+ return "refreshed-token", "", nil
+ }
+
+ messages := []Message{{Role: "user", Content: "Hello"}}
+ resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{"temperature": 0.7})
+ if err != nil {
+ t.Fatalf("Chat() error: %v", err)
+ }
+ if resp.Content != "Hi from Codex!" {
+ t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!")
+ }
+}
+
+func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/responses" {
+ http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound)
+ return
+ }
+
+ var reqBody map[string]interface{}
+ if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ if reqBody["model"] != codexDefaultModel {
+ http.Error(w, "unsupported model", http.StatusBadRequest)
+ return
+ }
+ if reqBody["stream"] != true {
+ http.Error(w, "stream must be true", http.StatusBadRequest)
+ return
+ }
+ if reqBody["instructions"] != codexDefaultInstructions {
+ http.Error(w, "missing default instructions", http.StatusBadRequest)
+ return
+ }
+
+ resp := map[string]interface{}{
+ "id": "resp_test",
+ "object": "response",
+ "status": "completed",
+ "output": []map[string]interface{}{
+ {
+ "id": "msg_1",
+ "type": "message",
+ "role": "assistant",
+ "status": "completed",
+ "content": []map[string]interface{}{
+ {"type": "output_text", "text": "Hi from Codex!"},
+ },
+ },
+ },
+ "usage": map[string]interface{}{
+ "input_tokens": 8,
+ "output_tokens": 4,
+ "total_tokens": 12,
+ "input_tokens_details": map[string]interface{}{"cached_tokens": 0},
+ "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0},
+ },
+ }
+ writeCompletedSSE(w, resp)
+ }))
+ defer server.Close()
+
+ provider := NewCodexProvider("test-token", "acc-123")
+ provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123")
+
+ messages := []Message{{Role: "user", Content: "Hello"}}
+ resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.2", nil)
+ if err != nil {
+ t.Fatalf("Chat() error: %v", err)
+ }
+ if resp.Content != "Hi from Codex!" {
+ t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!")
+ }
+}
+
func TestCodexProvider_GetDefaultModel(t *testing.T) {
p := NewCodexProvider("test-token", "")
- if got := p.GetDefaultModel(); got != "gpt-4o" {
- t.Errorf("GetDefaultModel() = %q, want %q", got, "gpt-4o")
+ if got := p.GetDefaultModel(); got != codexDefaultModel {
+ t.Errorf("GetDefaultModel() = %q, want %q", got, codexDefaultModel)
+ }
+}
+
+func TestResolveCodexModel(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantModel string
+ wantFallback bool
+ }{
+ {name: "empty", input: "", wantModel: codexDefaultModel, wantFallback: true},
+ {name: "unsupported namespace", input: "anthropic/claude-3.5", wantModel: codexDefaultModel, wantFallback: true},
+ {name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true},
+ {name: "openai prefix", input: "openai/gpt-5.2", wantModel: "gpt-5.2", wantFallback: false},
+ {name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotModel, reason := resolveCodexModel(tt.input)
+ if gotModel != tt.wantModel {
+ t.Fatalf("resolveCodexModel(%q) model = %q, want %q", tt.input, gotModel, tt.wantModel)
+ }
+ if tt.wantFallback && reason == "" {
+ t.Fatalf("resolveCodexModel(%q) expected fallback reason", tt.input)
+ }
+ if !tt.wantFallback && reason != "" {
+ t.Fatalf("resolveCodexModel(%q) unexpected fallback reason: %q", tt.input, reason)
+ }
+ })
}
}
@@ -268,3 +621,16 @@ func createOpenAITestClient(baseURL, token, accountID string) *openai.Client {
c := openai.NewClient(opts...)
return &c
}
+
+func writeCompletedSSE(w http.ResponseWriter, response map[string]interface{}) {
+ event := map[string]interface{}{
+ "type": "response.completed",
+ "sequence_number": 1,
+ "response": response,
+ }
+ b, _ := json.Marshal(event)
+ w.Header().Set("Content-Type", "text/event-stream")
+ fmt.Fprintf(w, "event: response.completed\n")
+ fmt.Fprintf(w, "data: %s\n\n", string(b))
+ fmt.Fprintf(w, "data: [DONE]\n\n")
+}
diff --git a/pkg/providers/cooldown.go b/pkg/providers/cooldown.go
new file mode 100644
index 000000000..b0d8608dc
--- /dev/null
+++ b/pkg/providers/cooldown.go
@@ -0,0 +1,207 @@
+package providers
+
+import (
+ "math"
+ "sync"
+ "time"
+)
+
+const (
+ defaultFailureWindow = 24 * time.Hour
+)
+
+// CooldownTracker manages per-provider cooldown state for the fallback chain.
+// Thread-safe via sync.RWMutex. In-memory only (resets on restart).
+type CooldownTracker struct {
+ mu sync.RWMutex
+ entries map[string]*cooldownEntry
+ failureWindow time.Duration
+ nowFunc func() time.Time // for testing
+}
+
+type cooldownEntry struct {
+ ErrorCount int
+ FailureCounts map[FailoverReason]int
+ CooldownEnd time.Time // standard cooldown expiry
+ DisabledUntil time.Time // billing-specific disable expiry
+ DisabledReason FailoverReason // reason for disable (billing)
+ LastFailure time.Time
+}
+
+// NewCooldownTracker creates a tracker with default 24h failure window.
+func NewCooldownTracker() *CooldownTracker {
+ return &CooldownTracker{
+ entries: make(map[string]*cooldownEntry),
+ failureWindow: defaultFailureWindow,
+ nowFunc: time.Now,
+ }
+}
+
+// MarkFailure records a failure for a provider and sets appropriate cooldown.
+// Resets error counts if last failure was more than failureWindow ago.
+func (ct *CooldownTracker) MarkFailure(provider string, reason FailoverReason) {
+ ct.mu.Lock()
+ defer ct.mu.Unlock()
+
+ now := ct.nowFunc()
+ entry := ct.getOrCreate(provider)
+
+ // 24h failure window reset: if no failure in failureWindow, reset counters.
+ if !entry.LastFailure.IsZero() && now.Sub(entry.LastFailure) > ct.failureWindow {
+ entry.ErrorCount = 0
+ entry.FailureCounts = make(map[FailoverReason]int)
+ }
+
+ entry.ErrorCount++
+ entry.FailureCounts[reason]++
+ entry.LastFailure = now
+
+ if reason == FailoverBilling {
+ billingCount := entry.FailureCounts[FailoverBilling]
+ entry.DisabledUntil = now.Add(calculateBillingCooldown(billingCount))
+ entry.DisabledReason = FailoverBilling
+ } else {
+ entry.CooldownEnd = now.Add(calculateStandardCooldown(entry.ErrorCount))
+ }
+}
+
+// MarkSuccess resets all counters and cooldowns for a provider.
+func (ct *CooldownTracker) MarkSuccess(provider string) {
+ ct.mu.Lock()
+ defer ct.mu.Unlock()
+
+ entry := ct.entries[provider]
+ if entry == nil {
+ return
+ }
+
+ entry.ErrorCount = 0
+ entry.FailureCounts = make(map[FailoverReason]int)
+ entry.CooldownEnd = time.Time{}
+ entry.DisabledUntil = time.Time{}
+ entry.DisabledReason = ""
+}
+
+// IsAvailable returns true if the provider is not in cooldown or disabled.
+func (ct *CooldownTracker) IsAvailable(provider string) bool {
+ ct.mu.RLock()
+ defer ct.mu.RUnlock()
+
+ entry := ct.entries[provider]
+ if entry == nil {
+ return true
+ }
+
+ now := ct.nowFunc()
+
+ // Billing disable takes precedence (longer cooldown).
+ if !entry.DisabledUntil.IsZero() && now.Before(entry.DisabledUntil) {
+ return false
+ }
+
+ // Standard cooldown.
+ if !entry.CooldownEnd.IsZero() && now.Before(entry.CooldownEnd) {
+ return false
+ }
+
+ return true
+}
+
+// CooldownRemaining returns how long until the provider becomes available.
+// Returns 0 if already available.
+func (ct *CooldownTracker) CooldownRemaining(provider string) time.Duration {
+ ct.mu.RLock()
+ defer ct.mu.RUnlock()
+
+ entry := ct.entries[provider]
+ if entry == nil {
+ return 0
+ }
+
+ now := ct.nowFunc()
+ var remaining time.Duration
+
+ if !entry.DisabledUntil.IsZero() && now.Before(entry.DisabledUntil) {
+ d := entry.DisabledUntil.Sub(now)
+ if d > remaining {
+ remaining = d
+ }
+ }
+
+ if !entry.CooldownEnd.IsZero() && now.Before(entry.CooldownEnd) {
+ d := entry.CooldownEnd.Sub(now)
+ if d > remaining {
+ remaining = d
+ }
+ }
+
+ return remaining
+}
+
+// ErrorCount returns the current error count for a provider.
+func (ct *CooldownTracker) ErrorCount(provider string) int {
+ ct.mu.RLock()
+ defer ct.mu.RUnlock()
+
+ entry := ct.entries[provider]
+ if entry == nil {
+ return 0
+ }
+ return entry.ErrorCount
+}
+
+// FailureCount returns the failure count for a specific reason.
+func (ct *CooldownTracker) FailureCount(provider string, reason FailoverReason) int {
+ ct.mu.RLock()
+ defer ct.mu.RUnlock()
+
+ entry := ct.entries[provider]
+ if entry == nil {
+ return 0
+ }
+ return entry.FailureCounts[reason]
+}
+
+func (ct *CooldownTracker) getOrCreate(provider string) *cooldownEntry {
+ entry := ct.entries[provider]
+ if entry == nil {
+ entry = &cooldownEntry{
+ FailureCounts: make(map[FailoverReason]int),
+ }
+ ct.entries[provider] = entry
+ }
+ return entry
+}
+
+// calculateStandardCooldown computes standard exponential backoff.
+// Formula from OpenClaw: min(1h, 1min * 5^min(n-1, 3))
+//
+// 1 error → 1 min
+// 2 errors → 5 min
+// 3 errors → 25 min
+// 4+ errors → 1 hour (cap)
+func calculateStandardCooldown(errorCount int) time.Duration {
+ n := max(1, errorCount)
+ exp := min(n-1, 3)
+ ms := 60_000 * int(math.Pow(5, float64(exp)))
+ ms = min(3_600_000, ms) // cap at 1 hour
+ return time.Duration(ms) * time.Millisecond
+}
+
+// calculateBillingCooldown computes billing-specific exponential backoff.
+// Formula from OpenClaw: min(24h, 5h * 2^min(n-1, 10))
+//
+// 1 error → 5 hours
+// 2 errors → 10 hours
+// 3 errors → 20 hours
+// 4+ errors → 24 hours (cap)
+func calculateBillingCooldown(billingErrorCount int) time.Duration {
+ const baseMs = 5 * 60 * 60 * 1000 // 5 hours
+ const maxMs = 24 * 60 * 60 * 1000 // 24 hours
+
+ n := max(1, billingErrorCount)
+ exp := min(n-1, 10)
+ raw := float64(baseMs) * math.Pow(2, float64(exp))
+ ms := int(math.Min(float64(maxMs), raw))
+ return time.Duration(ms) * time.Millisecond
+}
diff --git a/pkg/providers/cooldown_test.go b/pkg/providers/cooldown_test.go
new file mode 100644
index 000000000..47f43ad5c
--- /dev/null
+++ b/pkg/providers/cooldown_test.go
@@ -0,0 +1,269 @@
+package providers
+
+import (
+ "sync"
+ "testing"
+ "time"
+)
+
+func newTestTracker(now time.Time) (*CooldownTracker, *time.Time) {
+ current := now
+ ct := NewCooldownTracker()
+ ct.nowFunc = func() time.Time { return current }
+ return ct, ¤t
+}
+
+func TestCooldown_InitiallyAvailable(t *testing.T) {
+ ct := NewCooldownTracker()
+ if !ct.IsAvailable("openai") {
+ t.Error("new provider should be available")
+ }
+ if ct.ErrorCount("openai") != 0 {
+ t.Error("new provider should have 0 errors")
+ }
+}
+
+func TestCooldown_StandardEscalation(t *testing.T) {
+ now := time.Now()
+ ct, current := newTestTracker(now)
+
+ // 1st error → 1 min cooldown
+ ct.MarkFailure("openai", FailoverRateLimit)
+ if ct.IsAvailable("openai") {
+ t.Error("should be in cooldown after 1st error")
+ }
+
+ // Advance 61 seconds → available
+ *current = now.Add(61 * time.Second)
+ if !ct.IsAvailable("openai") {
+ t.Error("should be available after 1 min cooldown")
+ }
+
+ // 2nd error → 5 min cooldown
+ ct.MarkFailure("openai", FailoverRateLimit)
+ *current = now.Add(61*time.Second + 4*time.Minute)
+ if ct.IsAvailable("openai") {
+ t.Error("should be in cooldown (5 min) after 2nd error")
+ }
+ *current = now.Add(61*time.Second + 6*time.Minute)
+ if !ct.IsAvailable("openai") {
+ t.Error("should be available after 5 min cooldown")
+ }
+}
+
+func TestCooldown_StandardCap(t *testing.T) {
+ // Verify formula: 1m, 5m, 25m, 1h, 1h, 1h...
+ expected := []time.Duration{
+ 1 * time.Minute,
+ 5 * time.Minute,
+ 25 * time.Minute,
+ 1 * time.Hour,
+ 1 * time.Hour,
+ }
+
+ for i, want := range expected {
+ got := calculateStandardCooldown(i + 1)
+ if got != want {
+ t.Errorf("calculateStandardCooldown(%d) = %v, want %v", i+1, got, want)
+ }
+ }
+}
+
+func TestCooldown_BillingEscalation(t *testing.T) {
+ now := time.Now()
+ ct, current := newTestTracker(now)
+
+ // 1st billing error → 5h cooldown
+ ct.MarkFailure("openai", FailoverBilling)
+ if ct.IsAvailable("openai") {
+ t.Error("should be disabled after billing error")
+ }
+
+ // Advance 4h → still disabled
+ *current = now.Add(4 * time.Hour)
+ if ct.IsAvailable("openai") {
+ t.Error("should still be disabled (5h cooldown)")
+ }
+
+ // Advance 5h + 1s → available
+ *current = now.Add(5*time.Hour + 1*time.Second)
+ if !ct.IsAvailable("openai") {
+ t.Error("should be available after 5h billing cooldown")
+ }
+}
+
+func TestCooldown_BillingCap(t *testing.T) {
+ expected := []time.Duration{
+ 5 * time.Hour,
+ 10 * time.Hour,
+ 20 * time.Hour,
+ 24 * time.Hour,
+ 24 * time.Hour,
+ }
+
+ for i, want := range expected {
+ got := calculateBillingCooldown(i + 1)
+ if got != want {
+ t.Errorf("calculateBillingCooldown(%d) = %v, want %v", i+1, got, want)
+ }
+ }
+}
+
+func TestCooldown_SuccessReset(t *testing.T) {
+ ct := NewCooldownTracker()
+
+ ct.MarkFailure("openai", FailoverRateLimit)
+ ct.MarkFailure("openai", FailoverBilling)
+ if ct.ErrorCount("openai") != 2 {
+ t.Errorf("error count = %d, want 2", ct.ErrorCount("openai"))
+ }
+
+ ct.MarkSuccess("openai")
+ if ct.ErrorCount("openai") != 0 {
+ t.Errorf("error count after success = %d, want 0", ct.ErrorCount("openai"))
+ }
+ if !ct.IsAvailable("openai") {
+ t.Error("should be available after success")
+ }
+ if ct.FailureCount("openai", FailoverRateLimit) != 0 {
+ t.Error("failure counts should be reset after success")
+ }
+ if ct.FailureCount("openai", FailoverBilling) != 0 {
+ t.Error("billing failure count should be reset after success")
+ }
+}
+
+func TestCooldown_FailureWindowReset(t *testing.T) {
+ now := time.Now()
+ ct, current := newTestTracker(now)
+
+ // 4 errors → 1h cooldown
+ for i := 0; i < 4; i++ {
+ ct.MarkFailure("openai", FailoverRateLimit)
+ *current = current.Add(2 * time.Second) // small advance between errors
+ }
+ if ct.ErrorCount("openai") != 4 {
+ t.Errorf("error count = %d, want 4", ct.ErrorCount("openai"))
+ }
+
+ // Advance 25 hours (past 24h failure window)
+ *current = now.Add(25 * time.Hour)
+
+ // Next error should reset counters first, then increment to 1
+ ct.MarkFailure("openai", FailoverRateLimit)
+ if ct.ErrorCount("openai") != 1 {
+ t.Errorf("error count after window reset = %d, want 1 (reset + 1)", ct.ErrorCount("openai"))
+ }
+}
+
+func TestCooldown_PerReasonTracking(t *testing.T) {
+ ct := NewCooldownTracker()
+
+ ct.MarkFailure("openai", FailoverRateLimit)
+ ct.MarkFailure("openai", FailoverRateLimit)
+ ct.MarkFailure("openai", FailoverBilling)
+ ct.MarkFailure("openai", FailoverAuth)
+
+ if ct.FailureCount("openai", FailoverRateLimit) != 2 {
+ t.Errorf("rate_limit count = %d, want 2", ct.FailureCount("openai", FailoverRateLimit))
+ }
+ if ct.FailureCount("openai", FailoverBilling) != 1 {
+ t.Errorf("billing count = %d, want 1", ct.FailureCount("openai", FailoverBilling))
+ }
+ if ct.FailureCount("openai", FailoverAuth) != 1 {
+ t.Errorf("auth count = %d, want 1", ct.FailureCount("openai", FailoverAuth))
+ }
+ if ct.ErrorCount("openai") != 4 {
+ t.Errorf("total error count = %d, want 4", ct.ErrorCount("openai"))
+ }
+}
+
+func TestCooldown_BillingTakesPrecedence(t *testing.T) {
+ now := time.Now()
+ ct, current := newTestTracker(now)
+
+ // Standard cooldown (1 min) + billing disable (5h)
+ ct.MarkFailure("openai", FailoverRateLimit) // 1 min cooldown
+ ct.MarkFailure("openai", FailoverBilling) // 5h disable
+
+ // After 2 min: standard cooldown expired but billing still active
+ *current = now.Add(2 * time.Minute)
+ if ct.IsAvailable("openai") {
+ t.Error("billing disable should take precedence over standard cooldown")
+ }
+
+ // After 5h + 1s: both expired
+ *current = now.Add(5*time.Hour + 1*time.Second)
+ if !ct.IsAvailable("openai") {
+ t.Error("should be available after all cooldowns expire")
+ }
+}
+
+func TestCooldown_CooldownRemaining(t *testing.T) {
+ now := time.Now()
+ ct, current := newTestTracker(now)
+
+ // No failures → 0 remaining
+ if ct.CooldownRemaining("openai") != 0 {
+ t.Error("expected 0 remaining for new provider")
+ }
+
+ ct.MarkFailure("openai", FailoverRateLimit)
+
+ *current = now.Add(30 * time.Second)
+ remaining := ct.CooldownRemaining("openai")
+ if remaining <= 0 || remaining > 1*time.Minute {
+ t.Errorf("remaining = %v, expected ~30s", remaining)
+ }
+}
+
+func TestCooldown_SuccessOnUnknownProvider(t *testing.T) {
+ ct := NewCooldownTracker()
+ // Should not panic
+ ct.MarkSuccess("nonexistent")
+ if !ct.IsAvailable("nonexistent") {
+ t.Error("nonexistent provider should be available")
+ }
+}
+
+func TestCooldown_ConcurrentAccess(t *testing.T) {
+ ct := NewCooldownTracker()
+ var wg sync.WaitGroup
+
+ for i := 0; i < 100; i++ {
+ wg.Add(3)
+ go func() {
+ defer wg.Done()
+ ct.MarkFailure("openai", FailoverRateLimit)
+ }()
+ go func() {
+ defer wg.Done()
+ ct.IsAvailable("openai")
+ }()
+ go func() {
+ defer wg.Done()
+ ct.MarkSuccess("openai")
+ }()
+ }
+
+ wg.Wait()
+ // If we got here without panic, concurrent access is safe
+}
+
+func TestCooldown_MultipleProviders(t *testing.T) {
+ ct := NewCooldownTracker()
+
+ ct.MarkFailure("openai", FailoverRateLimit)
+ ct.MarkFailure("anthropic", FailoverBilling)
+
+ if ct.IsAvailable("openai") {
+ t.Error("openai should be in cooldown")
+ }
+ if ct.IsAvailable("anthropic") {
+ t.Error("anthropic should be in cooldown")
+ }
+ // groq was never touched
+ if !ct.IsAvailable("groq") {
+ t.Error("groq should be available")
+ }
+}
diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go
new file mode 100644
index 000000000..a0f003006
--- /dev/null
+++ b/pkg/providers/error_classifier.go
@@ -0,0 +1,253 @@
+package providers
+
+import (
+ "context"
+ "regexp"
+ "strings"
+)
+
+// errorPattern defines a single pattern (string or regex) for error classification.
+type errorPattern struct {
+ substring string
+ regex *regexp.Regexp
+}
+
+func substr(s string) errorPattern { return errorPattern{substring: s} }
+func rxp(r string) errorPattern { return errorPattern{regex: regexp.MustCompile("(?i)" + r)} }
+
+// Error patterns organized by FailoverReason, matching OpenClaw production (~40 patterns).
+var (
+ rateLimitPatterns = []errorPattern{
+ rxp(`rate[_ ]limit`),
+ substr("too many requests"),
+ substr("429"),
+ substr("exceeded your current quota"),
+ rxp(`exceeded.*quota`),
+ rxp(`resource has been exhausted`),
+ rxp(`resource.*exhausted`),
+ substr("resource_exhausted"),
+ substr("quota exceeded"),
+ substr("usage limit"),
+ }
+
+ overloadedPatterns = []errorPattern{
+ rxp(`overloaded_error`),
+ rxp(`"type"\s*:\s*"overloaded_error"`),
+ substr("overloaded"),
+ }
+
+ timeoutPatterns = []errorPattern{
+ substr("timeout"),
+ substr("timed out"),
+ substr("deadline exceeded"),
+ substr("context deadline exceeded"),
+ }
+
+ billingPatterns = []errorPattern{
+ rxp(`\b402\b`),
+ substr("payment required"),
+ substr("insufficient credits"),
+ substr("credit balance"),
+ substr("plans & billing"),
+ substr("insufficient balance"),
+ }
+
+ authPatterns = []errorPattern{
+ rxp(`invalid[_ ]?api[_ ]?key`),
+ substr("incorrect api key"),
+ substr("invalid token"),
+ substr("authentication"),
+ substr("re-authenticate"),
+ substr("oauth token refresh failed"),
+ substr("unauthorized"),
+ substr("forbidden"),
+ substr("access denied"),
+ substr("expired"),
+ substr("token has expired"),
+ rxp(`\b401\b`),
+ rxp(`\b403\b`),
+ substr("no credentials found"),
+ substr("no api key found"),
+ }
+
+ formatPatterns = []errorPattern{
+ substr("string should match pattern"),
+ substr("tool_use.id"),
+ substr("tool_use_id"),
+ substr("messages.1.content.1.tool_use.id"),
+ substr("invalid request format"),
+ }
+
+ imageDimensionPatterns = []errorPattern{
+ rxp(`image dimensions exceed max`),
+ }
+
+ imageSizePatterns = []errorPattern{
+ rxp(`image exceeds.*mb`),
+ }
+
+ // Transient HTTP status codes that map to timeout (server-side failures).
+ transientStatusCodes = map[int]bool{
+ 500: true, 502: true, 503: true,
+ 521: true, 522: true, 523: true, 524: true,
+ 529: true,
+ }
+)
+
+// ClassifyError classifies an error into a FailoverError with reason.
+// Returns nil if the error is not classifiable (unknown errors should not trigger fallback).
+func ClassifyError(err error, provider, model string) *FailoverError {
+ if err == nil {
+ return nil
+ }
+
+ // Context cancellation: user abort, never fallback.
+ if err == context.Canceled {
+ return nil
+ }
+
+ // Context deadline exceeded: treat as timeout, always fallback.
+ if err == context.DeadlineExceeded {
+ return &FailoverError{
+ Reason: FailoverTimeout,
+ Provider: provider,
+ Model: model,
+ Wrapped: err,
+ }
+ }
+
+ msg := strings.ToLower(err.Error())
+
+ // Image dimension/size errors: non-retriable, non-fallback.
+ if IsImageDimensionError(msg) || IsImageSizeError(msg) {
+ return &FailoverError{
+ Reason: FailoverFormat,
+ Provider: provider,
+ Model: model,
+ Wrapped: err,
+ }
+ }
+
+ // Try HTTP status code extraction first.
+ if status := extractHTTPStatus(msg); status > 0 {
+ if reason := classifyByStatus(status); reason != "" {
+ return &FailoverError{
+ Reason: reason,
+ Provider: provider,
+ Model: model,
+ Status: status,
+ Wrapped: err,
+ }
+ }
+ }
+
+ // Message pattern matching (priority order from OpenClaw).
+ if reason := classifyByMessage(msg); reason != "" {
+ return &FailoverError{
+ Reason: reason,
+ Provider: provider,
+ Model: model,
+ Wrapped: err,
+ }
+ }
+
+ return nil
+}
+
+// classifyByStatus maps HTTP status codes to FailoverReason.
+func classifyByStatus(status int) FailoverReason {
+ switch {
+ case status == 401 || status == 403:
+ return FailoverAuth
+ case status == 402:
+ return FailoverBilling
+ case status == 408:
+ return FailoverTimeout
+ case status == 429:
+ return FailoverRateLimit
+ case status == 400:
+ return FailoverFormat
+ case transientStatusCodes[status]:
+ return FailoverTimeout
+ }
+ return ""
+}
+
+// classifyByMessage matches error messages against patterns.
+// Priority order matters (from OpenClaw classifyFailoverReason).
+func classifyByMessage(msg string) FailoverReason {
+ if matchesAny(msg, rateLimitPatterns) {
+ return FailoverRateLimit
+ }
+ if matchesAny(msg, overloadedPatterns) {
+ return FailoverRateLimit // Overloaded treated as rate_limit
+ }
+ if matchesAny(msg, billingPatterns) {
+ return FailoverBilling
+ }
+ if matchesAny(msg, timeoutPatterns) {
+ return FailoverTimeout
+ }
+ if matchesAny(msg, authPatterns) {
+ return FailoverAuth
+ }
+ if matchesAny(msg, formatPatterns) {
+ return FailoverFormat
+ }
+ return ""
+}
+
+// extractHTTPStatus extracts an HTTP status code from an error message.
+// Looks for patterns like "status: 429", "status 429", "HTTP 429", or standalone "429".
+func extractHTTPStatus(msg string) int {
+ // Common patterns in Go HTTP error messages
+ patterns := []*regexp.Regexp{
+ regexp.MustCompile(`status[:\s]+(\d{3})`),
+ regexp.MustCompile(`HTTP[/\s]+\d*\.?\d*\s+(\d{3})`),
+ }
+
+ for _, p := range patterns {
+ if m := p.FindStringSubmatch(msg); len(m) > 1 {
+ return parseDigits(m[1])
+ }
+ }
+
+ return 0
+}
+
+// IsImageDimensionError returns true if the message indicates an image dimension error.
+func IsImageDimensionError(msg string) bool {
+ return matchesAny(msg, imageDimensionPatterns)
+}
+
+// IsImageSizeError returns true if the message indicates an image file size error.
+func IsImageSizeError(msg string) bool {
+ return matchesAny(msg, imageSizePatterns)
+}
+
+// matchesAny checks if msg matches any of the patterns.
+func matchesAny(msg string, patterns []errorPattern) bool {
+ for _, p := range patterns {
+ if p.regex != nil {
+ if p.regex.MatchString(msg) {
+ return true
+ }
+ } else if p.substring != "" {
+ if strings.Contains(msg, p.substring) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// parseDigits converts a string of digits to an int.
+func parseDigits(s string) int {
+ n := 0
+ for _, c := range s {
+ if c >= '0' && c <= '9' {
+ n = n*10 + int(c-'0')
+ }
+ }
+ return n
+}
diff --git a/pkg/providers/error_classifier_test.go b/pkg/providers/error_classifier_test.go
new file mode 100644
index 000000000..865aea57a
--- /dev/null
+++ b/pkg/providers/error_classifier_test.go
@@ -0,0 +1,337 @@
+package providers
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "testing"
+)
+
+func TestClassifyError_Nil(t *testing.T) {
+ result := ClassifyError(nil, "openai", "gpt-4")
+ if result != nil {
+ t.Errorf("expected nil for nil error, got %+v", result)
+ }
+}
+
+func TestClassifyError_ContextCanceled(t *testing.T) {
+ result := ClassifyError(context.Canceled, "openai", "gpt-4")
+ if result != nil {
+ t.Errorf("expected nil for context.Canceled (user abort), got %+v", result)
+ }
+}
+
+func TestClassifyError_ContextDeadlineExceeded(t *testing.T) {
+ result := ClassifyError(context.DeadlineExceeded, "openai", "gpt-4")
+ if result == nil {
+ t.Fatal("expected non-nil for deadline exceeded")
+ }
+ if result.Reason != FailoverTimeout {
+ t.Errorf("reason = %q, want timeout", result.Reason)
+ }
+}
+
+func TestClassifyError_StatusCodes(t *testing.T) {
+ tests := []struct {
+ status int
+ reason FailoverReason
+ }{
+ {401, FailoverAuth},
+ {403, FailoverAuth},
+ {402, FailoverBilling},
+ {408, FailoverTimeout},
+ {429, FailoverRateLimit},
+ {400, FailoverFormat},
+ {500, FailoverTimeout},
+ {502, FailoverTimeout},
+ {503, FailoverTimeout},
+ {521, FailoverTimeout},
+ {522, FailoverTimeout},
+ {523, FailoverTimeout},
+ {524, FailoverTimeout},
+ {529, FailoverTimeout},
+ }
+
+ for _, tt := range tests {
+ err := fmt.Errorf("API error: status: %d something went wrong", tt.status)
+ result := ClassifyError(err, "test", "model")
+ if result == nil {
+ t.Errorf("status %d: expected non-nil", tt.status)
+ continue
+ }
+ if result.Reason != tt.reason {
+ t.Errorf("status %d: reason = %q, want %q", tt.status, result.Reason, tt.reason)
+ }
+ }
+}
+
+func TestClassifyError_RateLimitPatterns(t *testing.T) {
+ patterns := []string{
+ "rate limit exceeded",
+ "rate_limit reached",
+ "too many requests",
+ "exceeded your current quota",
+ "resource has been exhausted",
+ "resource_exhausted",
+ "quota exceeded",
+ "usage limit reached",
+ }
+
+ for _, msg := range patterns {
+ err := errors.New(msg)
+ result := ClassifyError(err, "openai", "gpt-4")
+ if result == nil {
+ t.Errorf("pattern %q: expected non-nil", msg)
+ continue
+ }
+ if result.Reason != FailoverRateLimit {
+ t.Errorf("pattern %q: reason = %q, want rate_limit", msg, result.Reason)
+ }
+ }
+}
+
+func TestClassifyError_OverloadedPatterns(t *testing.T) {
+ patterns := []string{
+ "overloaded_error",
+ `{"type": "overloaded_error"}`,
+ "server is overloaded",
+ }
+
+ for _, msg := range patterns {
+ err := errors.New(msg)
+ result := ClassifyError(err, "anthropic", "claude")
+ if result == nil {
+ t.Errorf("pattern %q: expected non-nil", msg)
+ continue
+ }
+ // Overloaded is treated as rate_limit
+ if result.Reason != FailoverRateLimit {
+ t.Errorf("pattern %q: reason = %q, want rate_limit", msg, result.Reason)
+ }
+ }
+}
+
+func TestClassifyError_BillingPatterns(t *testing.T) {
+ patterns := []string{
+ "payment required",
+ "insufficient credits",
+ "credit balance too low",
+ "plans & billing page",
+ "insufficient balance",
+ }
+
+ for _, msg := range patterns {
+ err := errors.New(msg)
+ result := ClassifyError(err, "openai", "gpt-4")
+ if result == nil {
+ t.Errorf("pattern %q: expected non-nil", msg)
+ continue
+ }
+ if result.Reason != FailoverBilling {
+ t.Errorf("pattern %q: reason = %q, want billing", msg, result.Reason)
+ }
+ }
+}
+
+func TestClassifyError_TimeoutPatterns(t *testing.T) {
+ patterns := []string{
+ "request timeout",
+ "connection timed out",
+ "deadline exceeded",
+ "context deadline exceeded",
+ }
+
+ for _, msg := range patterns {
+ err := errors.New(msg)
+ result := ClassifyError(err, "openai", "gpt-4")
+ if result == nil {
+ t.Errorf("pattern %q: expected non-nil", msg)
+ continue
+ }
+ if result.Reason != FailoverTimeout {
+ t.Errorf("pattern %q: reason = %q, want timeout", msg, result.Reason)
+ }
+ }
+}
+
+func TestClassifyError_AuthPatterns(t *testing.T) {
+ patterns := []string{
+ "invalid api key",
+ "invalid_api_key",
+ "incorrect api key",
+ "invalid token",
+ "authentication failed",
+ "re-authenticate",
+ "oauth token refresh failed",
+ "unauthorized access",
+ "forbidden",
+ "access denied",
+ "expired",
+ "token has expired",
+ "no credentials found",
+ "no api key found",
+ }
+
+ for _, msg := range patterns {
+ err := errors.New(msg)
+ result := ClassifyError(err, "openai", "gpt-4")
+ if result == nil {
+ t.Errorf("pattern %q: expected non-nil", msg)
+ continue
+ }
+ if result.Reason != FailoverAuth {
+ t.Errorf("pattern %q: reason = %q, want auth", msg, result.Reason)
+ }
+ }
+}
+
+func TestClassifyError_FormatPatterns(t *testing.T) {
+ patterns := []string{
+ "string should match pattern",
+ "tool_use.id is required",
+ "invalid tool_use_id",
+ "messages.1.content.1.tool_use.id must be valid",
+ "invalid request format",
+ }
+
+ for _, msg := range patterns {
+ err := errors.New(msg)
+ result := ClassifyError(err, "anthropic", "claude")
+ if result == nil {
+ t.Errorf("pattern %q: expected non-nil", msg)
+ continue
+ }
+ if result.Reason != FailoverFormat {
+ t.Errorf("pattern %q: reason = %q, want format", msg, result.Reason)
+ }
+ }
+}
+
+func TestClassifyError_ImageDimensionError(t *testing.T) {
+ err := errors.New("image dimensions exceed max allowed 2048x2048")
+ result := ClassifyError(err, "openai", "gpt-4o")
+ if result == nil {
+ t.Fatal("expected non-nil for image dimension error")
+ }
+ if result.Reason != FailoverFormat {
+ t.Errorf("reason = %q, want format", result.Reason)
+ }
+ if result.IsRetriable() {
+ t.Error("image dimension error should not be retriable")
+ }
+}
+
+func TestClassifyError_ImageSizeError(t *testing.T) {
+ err := errors.New("image exceeds 20 mb limit")
+ result := ClassifyError(err, "openai", "gpt-4o")
+ if result == nil {
+ t.Fatal("expected non-nil for image size error")
+ }
+ if result.Reason != FailoverFormat {
+ t.Errorf("reason = %q, want format", result.Reason)
+ }
+}
+
+func TestClassifyError_UnknownError(t *testing.T) {
+ err := errors.New("some completely random error")
+ result := ClassifyError(err, "openai", "gpt-4")
+ if result != nil {
+ t.Errorf("expected nil for unknown error, got %+v", result)
+ }
+}
+
+func TestClassifyError_ProviderModelPropagation(t *testing.T) {
+ err := errors.New("rate limit exceeded")
+ result := ClassifyError(err, "my-provider", "my-model")
+ if result == nil {
+ t.Fatal("expected non-nil")
+ }
+ if result.Provider != "my-provider" {
+ t.Errorf("provider = %q, want my-provider", result.Provider)
+ }
+ if result.Model != "my-model" {
+ t.Errorf("model = %q, want my-model", result.Model)
+ }
+}
+
+func TestFailoverError_IsRetriable(t *testing.T) {
+ tests := []struct {
+ reason FailoverReason
+ retriable bool
+ }{
+ {FailoverAuth, true},
+ {FailoverRateLimit, true},
+ {FailoverBilling, true},
+ {FailoverTimeout, true},
+ {FailoverOverloaded, true},
+ {FailoverFormat, false},
+ {FailoverUnknown, true},
+ }
+
+ for _, tt := range tests {
+ fe := &FailoverError{Reason: tt.reason}
+ if fe.IsRetriable() != tt.retriable {
+ t.Errorf("IsRetriable(%q) = %v, want %v", tt.reason, fe.IsRetriable(), tt.retriable)
+ }
+ }
+}
+
+func TestFailoverError_ErrorString(t *testing.T) {
+ fe := &FailoverError{
+ Reason: FailoverRateLimit,
+ Provider: "openai",
+ Model: "gpt-4",
+ Status: 429,
+ Wrapped: errors.New("too many requests"),
+ }
+ s := fe.Error()
+ if s == "" {
+ t.Error("expected non-empty error string")
+ }
+}
+
+func TestFailoverError_Unwrap(t *testing.T) {
+ inner := errors.New("inner error")
+ fe := &FailoverError{Reason: FailoverTimeout, Wrapped: inner}
+ if fe.Unwrap() != inner {
+ t.Error("Unwrap should return wrapped error")
+ }
+}
+
+func TestExtractHTTPStatus(t *testing.T) {
+ tests := []struct {
+ msg string
+ want int
+ }{
+ {"status: 429 rate limited", 429},
+ {"status 401 unauthorized", 401},
+ {"HTTP/1.1 502 Bad Gateway", 502},
+ {"no status code here", 0},
+ {"random number 12345", 0},
+ }
+
+ for _, tt := range tests {
+ got := extractHTTPStatus(tt.msg)
+ if got != tt.want {
+ t.Errorf("extractHTTPStatus(%q) = %d, want %d", tt.msg, got, tt.want)
+ }
+ }
+}
+
+func TestIsImageDimensionError(t *testing.T) {
+ if !IsImageDimensionError("image dimensions exceed max 4096x4096") {
+ t.Error("should match image dimensions exceed max")
+ }
+ if IsImageDimensionError("normal error message") {
+ t.Error("should not match normal error")
+ }
+}
+
+func TestIsImageSizeError(t *testing.T) {
+ if !IsImageSizeError("image exceeds 20 mb") {
+ t.Error("should match image exceeds mb")
+ }
+ if IsImageSizeError("normal error message") {
+ t.Error("should not match normal error")
+ }
+}
diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go
new file mode 100644
index 000000000..b6f1b5e21
--- /dev/null
+++ b/pkg/providers/factory.go
@@ -0,0 +1,307 @@
+package providers
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/auth"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+const defaultAnthropicAPIBase = "https://api.anthropic.com/v1"
+
+var getCredential = auth.GetCredential
+
+type providerType int
+
+const (
+ providerTypeHTTPCompat providerType = iota
+ providerTypeClaudeAuth
+ providerTypeCodexAuth
+ providerTypeCodexCLIToken
+ providerTypeClaudeCLI
+ providerTypeCodexCLI
+ providerTypeGitHubCopilot
+)
+
+type providerSelection struct {
+ providerType providerType
+ apiKey string
+ apiBase string
+ proxy string
+ model string
+ workspace string
+ connectMode string
+ enableWebSearch bool
+}
+
+func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
+ model := cfg.Agents.Defaults.Model
+ providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
+ lowerModel := strings.ToLower(model)
+
+ sel := providerSelection{
+ providerType: providerTypeHTTPCompat,
+ model: model,
+ }
+
+ // First, prefer explicit provider configuration.
+ if providerName != "" {
+ switch providerName {
+ case "groq":
+ if cfg.Providers.Groq.APIKey != "" {
+ sel.apiKey = cfg.Providers.Groq.APIKey
+ sel.apiBase = cfg.Providers.Groq.APIBase
+ sel.proxy = cfg.Providers.Groq.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.groq.com/openai/v1"
+ }
+ }
+ case "openai", "gpt":
+ if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" {
+ sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch
+ if cfg.Providers.OpenAI.AuthMethod == "codex-cli" {
+ sel.providerType = providerTypeCodexCLIToken
+ return sel, nil
+ }
+ if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
+ sel.providerType = providerTypeCodexAuth
+ return sel, nil
+ }
+ sel.apiKey = cfg.Providers.OpenAI.APIKey
+ sel.apiBase = cfg.Providers.OpenAI.APIBase
+ sel.proxy = cfg.Providers.OpenAI.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.openai.com/v1"
+ }
+ }
+ case "anthropic", "claude":
+ if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" {
+ if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
+ sel.apiBase = cfg.Providers.Anthropic.APIBase
+ if sel.apiBase == "" {
+ sel.apiBase = defaultAnthropicAPIBase
+ }
+ sel.providerType = providerTypeClaudeAuth
+ return sel, nil
+ }
+ sel.apiKey = cfg.Providers.Anthropic.APIKey
+ sel.apiBase = cfg.Providers.Anthropic.APIBase
+ sel.proxy = cfg.Providers.Anthropic.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = defaultAnthropicAPIBase
+ }
+ }
+ case "openrouter":
+ if cfg.Providers.OpenRouter.APIKey != "" {
+ sel.apiKey = cfg.Providers.OpenRouter.APIKey
+ sel.proxy = cfg.Providers.OpenRouter.Proxy
+ if cfg.Providers.OpenRouter.APIBase != "" {
+ sel.apiBase = cfg.Providers.OpenRouter.APIBase
+ } else {
+ sel.apiBase = "https://openrouter.ai/api/v1"
+ }
+ }
+ case "zhipu", "glm":
+ if cfg.Providers.Zhipu.APIKey != "" {
+ sel.apiKey = cfg.Providers.Zhipu.APIKey
+ sel.apiBase = cfg.Providers.Zhipu.APIBase
+ sel.proxy = cfg.Providers.Zhipu.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://open.bigmodel.cn/api/paas/v4"
+ }
+ }
+ case "gemini", "google":
+ if cfg.Providers.Gemini.APIKey != "" {
+ sel.apiKey = cfg.Providers.Gemini.APIKey
+ sel.apiBase = cfg.Providers.Gemini.APIBase
+ sel.proxy = cfg.Providers.Gemini.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://generativelanguage.googleapis.com/v1beta"
+ }
+ }
+ case "vllm":
+ if cfg.Providers.VLLM.APIBase != "" {
+ sel.apiKey = cfg.Providers.VLLM.APIKey
+ sel.apiBase = cfg.Providers.VLLM.APIBase
+ sel.proxy = cfg.Providers.VLLM.Proxy
+ }
+ case "shengsuanyun":
+ if cfg.Providers.ShengSuanYun.APIKey != "" {
+ sel.apiKey = cfg.Providers.ShengSuanYun.APIKey
+ sel.apiBase = cfg.Providers.ShengSuanYun.APIBase
+ sel.proxy = cfg.Providers.ShengSuanYun.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://router.shengsuanyun.com/api/v1"
+ }
+ }
+ case "nvidia":
+ if cfg.Providers.Nvidia.APIKey != "" {
+ sel.apiKey = cfg.Providers.Nvidia.APIKey
+ sel.apiBase = cfg.Providers.Nvidia.APIBase
+ sel.proxy = cfg.Providers.Nvidia.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://integrate.api.nvidia.com/v1"
+ }
+ }
+ case "claude-cli", "claude-code", "claudecode":
+ workspace := cfg.WorkspacePath()
+ if workspace == "" {
+ workspace = "."
+ }
+ sel.providerType = providerTypeClaudeCLI
+ sel.workspace = workspace
+ return sel, nil
+ case "codex-cli", "codex-code":
+ workspace := cfg.WorkspacePath()
+ if workspace == "" {
+ workspace = "."
+ }
+ sel.providerType = providerTypeCodexCLI
+ sel.workspace = workspace
+ return sel, nil
+ case "deepseek":
+ if cfg.Providers.DeepSeek.APIKey != "" {
+ sel.apiKey = cfg.Providers.DeepSeek.APIKey
+ sel.apiBase = cfg.Providers.DeepSeek.APIBase
+ sel.proxy = cfg.Providers.DeepSeek.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.deepseek.com/v1"
+ }
+ if model != "deepseek-chat" && model != "deepseek-reasoner" {
+ sel.model = "deepseek-chat"
+ }
+ }
+ case "github_copilot", "copilot":
+ sel.providerType = providerTypeGitHubCopilot
+ if cfg.Providers.GitHubCopilot.APIBase != "" {
+ sel.apiBase = cfg.Providers.GitHubCopilot.APIBase
+ } else {
+ sel.apiBase = "localhost:4321"
+ }
+ sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode
+ return sel, nil
+ }
+ }
+
+ // Fallback: infer provider from model and configured keys.
+ if sel.apiKey == "" && sel.apiBase == "" {
+ switch {
+ case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "":
+ sel.apiKey = cfg.Providers.Moonshot.APIKey
+ sel.apiBase = cfg.Providers.Moonshot.APIBase
+ sel.proxy = cfg.Providers.Moonshot.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.moonshot.cn/v1"
+ }
+ case strings.HasPrefix(model, "openrouter/") ||
+ strings.HasPrefix(model, "anthropic/") ||
+ strings.HasPrefix(model, "openai/") ||
+ strings.HasPrefix(model, "meta-llama/") ||
+ strings.HasPrefix(model, "deepseek/") ||
+ strings.HasPrefix(model, "google/"):
+ sel.apiKey = cfg.Providers.OpenRouter.APIKey
+ sel.proxy = cfg.Providers.OpenRouter.Proxy
+ if cfg.Providers.OpenRouter.APIBase != "" {
+ sel.apiBase = cfg.Providers.OpenRouter.APIBase
+ } else {
+ sel.apiBase = "https://openrouter.ai/api/v1"
+ }
+ case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) &&
+ (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
+ if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
+ sel.apiBase = cfg.Providers.Anthropic.APIBase
+ if sel.apiBase == "" {
+ sel.apiBase = defaultAnthropicAPIBase
+ }
+ sel.providerType = providerTypeClaudeAuth
+ return sel, nil
+ }
+ sel.apiKey = cfg.Providers.Anthropic.APIKey
+ sel.apiBase = cfg.Providers.Anthropic.APIBase
+ sel.proxy = cfg.Providers.Anthropic.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = defaultAnthropicAPIBase
+ }
+ case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) &&
+ (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
+ sel.enableWebSearch = cfg.Providers.OpenAI.WebSearch
+ if cfg.Providers.OpenAI.AuthMethod == "codex-cli" {
+ sel.providerType = providerTypeCodexCLIToken
+ return sel, nil
+ }
+ if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
+ sel.providerType = providerTypeCodexAuth
+ return sel, nil
+ }
+ sel.apiKey = cfg.Providers.OpenAI.APIKey
+ sel.apiBase = cfg.Providers.OpenAI.APIBase
+ sel.proxy = cfg.Providers.OpenAI.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.openai.com/v1"
+ }
+ case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
+ sel.apiKey = cfg.Providers.Gemini.APIKey
+ sel.apiBase = cfg.Providers.Gemini.APIBase
+ sel.proxy = cfg.Providers.Gemini.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://generativelanguage.googleapis.com/v1beta"
+ }
+ case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "":
+ sel.apiKey = cfg.Providers.Zhipu.APIKey
+ sel.apiBase = cfg.Providers.Zhipu.APIBase
+ sel.proxy = cfg.Providers.Zhipu.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://open.bigmodel.cn/api/paas/v4"
+ }
+ case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
+ sel.apiKey = cfg.Providers.Groq.APIKey
+ sel.apiBase = cfg.Providers.Groq.APIBase
+ sel.proxy = cfg.Providers.Groq.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.groq.com/openai/v1"
+ }
+ case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "":
+ sel.apiKey = cfg.Providers.Nvidia.APIKey
+ sel.apiBase = cfg.Providers.Nvidia.APIBase
+ sel.proxy = cfg.Providers.Nvidia.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://integrate.api.nvidia.com/v1"
+ }
+ case (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "":
+ sel.apiKey = cfg.Providers.Ollama.APIKey
+ sel.apiBase = cfg.Providers.Ollama.APIBase
+ sel.proxy = cfg.Providers.Ollama.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "http://localhost:11434/v1"
+ }
+ case cfg.Providers.VLLM.APIBase != "":
+ sel.apiKey = cfg.Providers.VLLM.APIKey
+ sel.apiBase = cfg.Providers.VLLM.APIBase
+ sel.proxy = cfg.Providers.VLLM.Proxy
+ default:
+ if cfg.Providers.OpenRouter.APIKey != "" {
+ sel.apiKey = cfg.Providers.OpenRouter.APIKey
+ sel.proxy = cfg.Providers.OpenRouter.Proxy
+ if cfg.Providers.OpenRouter.APIBase != "" {
+ sel.apiBase = cfg.Providers.OpenRouter.APIBase
+ } else {
+ sel.apiBase = "https://openrouter.ai/api/v1"
+ }
+ } else {
+ return providerSelection{}, fmt.Errorf("no API key configured for model: %s", model)
+ }
+ }
+ }
+
+ if sel.providerType == providerTypeHTTPCompat {
+ if sel.apiKey == "" && !strings.HasPrefix(model, "bedrock/") {
+ return providerSelection{}, fmt.Errorf("no API key configured for provider (model: %s)", model)
+ }
+ if sel.apiBase == "" {
+ return providerSelection{}, fmt.Errorf("no API base configured for provider (model: %s)", model)
+ }
+ }
+
+ return sel, nil
+}
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
new file mode 100644
index 000000000..74fe8a36c
--- /dev/null
+++ b/pkg/providers/factory_provider.go
@@ -0,0 +1,192 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package providers
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
+func createClaudeAuthProvider() (LLMProvider, error) {
+ cred, err := getCredential("anthropic")
+ if err != nil {
+ return nil, fmt.Errorf("loading auth credentials: %w", err)
+ }
+ if cred == nil {
+ return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
+ }
+ return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil
+}
+
+// createCodexAuthProvider creates a Codex provider using OAuth credentials from auth store.
+func createCodexAuthProvider() (LLMProvider, error) {
+ cred, err := getCredential("openai")
+ if err != nil {
+ return nil, fmt.Errorf("loading auth credentials: %w", err)
+ }
+ if cred == nil {
+ return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai")
+ }
+ return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil
+}
+
+// ExtractProtocol extracts the protocol prefix and model identifier from a model string.
+// If no prefix is specified, it defaults to "openai".
+// Examples:
+// - "openai/gpt-4o" -> ("openai", "gpt-4o")
+// - "anthropic/claude-sonnet-4.6" -> ("anthropic", "claude-sonnet-4.6")
+// - "gpt-4o" -> ("openai", "gpt-4o") // default protocol
+func ExtractProtocol(model string) (protocol, modelID string) {
+ model = strings.TrimSpace(model)
+ protocol, modelID, found := strings.Cut(model, "/")
+ if !found {
+ return "openai", model
+ }
+ return protocol, modelID
+}
+
+// CreateProviderFromConfig creates a provider based on the ModelConfig.
+// It uses the protocol prefix in the Model field to determine which provider to create.
+// Supported protocols: openai, anthropic, antigravity, claude-cli, codex-cli, github-copilot
+// Returns the provider, the model ID (without protocol prefix), and any error.
+func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
+ if cfg == nil {
+ return nil, "", fmt.Errorf("config is nil")
+ }
+
+ if cfg.Model == "" {
+ return nil, "", fmt.Errorf("model is required")
+ }
+
+ protocol, modelID := ExtractProtocol(cfg.Model)
+
+ switch protocol {
+ case "openai":
+ // OpenAI with OAuth/token auth (Codex-style)
+ if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
+ provider, err := createCodexAuthProvider()
+ if err != nil {
+ return nil, "", err
+ }
+ return provider, modelID, nil
+ }
+ // OpenAI with API key
+ if cfg.APIKey == "" && cfg.APIBase == "" {
+ return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
+ }
+ apiBase := cfg.APIBase
+ if apiBase == "" {
+ apiBase = getDefaultAPIBase(protocol)
+ }
+ return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
+
+ case "openrouter", "groq", "zhipu", "gemini", "nvidia",
+ "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
+ "volcengine", "vllm", "qwen":
+ // All other OpenAI-compatible HTTP providers
+ if cfg.APIKey == "" && cfg.APIBase == "" {
+ return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
+ }
+ apiBase := cfg.APIBase
+ if apiBase == "" {
+ apiBase = getDefaultAPIBase(protocol)
+ }
+ return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
+
+ case "anthropic":
+ if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
+ // Use OAuth credentials from auth store
+ provider, err := createClaudeAuthProvider()
+ if err != nil {
+ return nil, "", err
+ }
+ return provider, modelID, nil
+ }
+ // Use API key with HTTP API
+ apiBase := cfg.APIBase
+ if apiBase == "" {
+ apiBase = "https://api.anthropic.com/v1"
+ }
+ if cfg.APIKey == "" {
+ return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
+ }
+ return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
+
+ case "antigravity":
+ return NewAntigravityProvider(), modelID, nil
+
+ case "claude-cli", "claudecli":
+ workspace := cfg.Workspace
+ if workspace == "" {
+ workspace = "."
+ }
+ return NewClaudeCliProvider(workspace), modelID, nil
+
+ case "codex-cli", "codexcli":
+ workspace := cfg.Workspace
+ if workspace == "" {
+ workspace = "."
+ }
+ return NewCodexCliProvider(workspace), modelID, nil
+
+ case "github-copilot", "copilot":
+ apiBase := cfg.APIBase
+ if apiBase == "" {
+ apiBase = "localhost:4321"
+ }
+ connectMode := cfg.ConnectMode
+ if connectMode == "" {
+ connectMode = "grpc"
+ }
+ provider, err := NewGitHubCopilotProvider(apiBase, connectMode, modelID)
+ if err != nil {
+ return nil, "", err
+ }
+ return provider, modelID, nil
+
+ default:
+ return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model)
+ }
+}
+
+// getDefaultAPIBase returns the default API base URL for a given protocol.
+func getDefaultAPIBase(protocol string) string {
+ switch protocol {
+ case "openai":
+ return "https://api.openai.com/v1"
+ case "openrouter":
+ return "https://openrouter.ai/api/v1"
+ case "groq":
+ return "https://api.groq.com/openai/v1"
+ case "zhipu":
+ return "https://open.bigmodel.cn/api/paas/v4"
+ case "gemini":
+ return "https://generativelanguage.googleapis.com/v1beta"
+ case "nvidia":
+ return "https://integrate.api.nvidia.com/v1"
+ case "ollama":
+ return "http://localhost:11434/v1"
+ case "moonshot":
+ return "https://api.moonshot.cn/v1"
+ case "shengsuanyun":
+ return "https://router.shengsuanyun.com/api/v1"
+ case "deepseek":
+ return "https://api.deepseek.com/v1"
+ case "cerebras":
+ return "https://api.cerebras.ai/v1"
+ case "volcengine":
+ return "https://ark.cn-beijing.volces.com/api/v3"
+ case "qwen":
+ return "https://dashscope.aliyuncs.com/compatible-mode/v1"
+ case "vllm":
+ return "http://localhost:8000/v1"
+ default:
+ return ""
+ }
+}
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
new file mode 100644
index 000000000..6b133101a
--- /dev/null
+++ b/pkg/providers/factory_provider_test.go
@@ -0,0 +1,249 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package providers
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestExtractProtocol(t *testing.T) {
+ tests := []struct {
+ name string
+ model string
+ wantProtocol string
+ wantModelID string
+ }{
+ {
+ name: "openai with prefix",
+ model: "openai/gpt-4o",
+ wantProtocol: "openai",
+ wantModelID: "gpt-4o",
+ },
+ {
+ name: "anthropic with prefix",
+ model: "anthropic/claude-sonnet-4.6",
+ wantProtocol: "anthropic",
+ wantModelID: "claude-sonnet-4.6",
+ },
+ {
+ name: "no prefix - defaults to openai",
+ model: "gpt-4o",
+ wantProtocol: "openai",
+ wantModelID: "gpt-4o",
+ },
+ {
+ name: "groq with prefix",
+ model: "groq/llama-3.1-70b",
+ wantProtocol: "groq",
+ wantModelID: "llama-3.1-70b",
+ },
+ {
+ name: "empty string",
+ model: "",
+ wantProtocol: "openai",
+ wantModelID: "",
+ },
+ {
+ name: "with whitespace",
+ model: " openai/gpt-4 ",
+ wantProtocol: "openai",
+ wantModelID: "gpt-4",
+ },
+ {
+ name: "multiple slashes",
+ model: "nvidia/meta/llama-3.1-8b",
+ wantProtocol: "nvidia",
+ wantModelID: "meta/llama-3.1-8b",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ protocol, modelID := ExtractProtocol(tt.model)
+ if protocol != tt.wantProtocol {
+ t.Errorf("ExtractProtocol(%q) protocol = %q, want %q", tt.model, protocol, tt.wantProtocol)
+ }
+ if modelID != tt.wantModelID {
+ t.Errorf("ExtractProtocol(%q) modelID = %q, want %q", tt.model, modelID, tt.wantModelID)
+ }
+ })
+ }
+}
+
+func TestCreateProviderFromConfig_OpenAI(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-openai",
+ Model: "openai/gpt-4o",
+ APIKey: "test-key",
+ APIBase: "https://api.example.com/v1",
+ }
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+ if modelID != "gpt-4o" {
+ t.Errorf("modelID = %q, want %q", modelID, "gpt-4o")
+ }
+}
+
+func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
+ tests := []struct {
+ name string
+ protocol string
+ }{
+ {"openai", "openai"},
+ {"groq", "groq"},
+ {"openrouter", "openrouter"},
+ {"cerebras", "cerebras"},
+ {"qwen", "qwen"},
+ {"vllm", "vllm"},
+ {"deepseek", "deepseek"},
+ {"ollama", "ollama"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-" + tt.protocol,
+ Model: tt.protocol + "/test-model",
+ APIKey: "test-key",
+ }
+
+ provider, _, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+
+ // Verify we got an HTTPProvider for all these protocols
+ if _, ok := provider.(*HTTPProvider); !ok {
+ t.Fatalf("expected *HTTPProvider, got %T", provider)
+ }
+ })
+ }
+}
+
+func TestCreateProviderFromConfig_Anthropic(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-anthropic",
+ Model: "anthropic/claude-sonnet-4.6",
+ APIKey: "test-key",
+ }
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+ if modelID != "claude-sonnet-4.6" {
+ t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4.6")
+ }
+}
+
+func TestCreateProviderFromConfig_Antigravity(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-antigravity",
+ Model: "antigravity/gemini-2.0-flash",
+ }
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+ if modelID != "gemini-2.0-flash" {
+ t.Errorf("modelID = %q, want %q", modelID, "gemini-2.0-flash")
+ }
+}
+
+func TestCreateProviderFromConfig_ClaudeCLI(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-claude-cli",
+ Model: "claude-cli/claude-sonnet-4.6",
+ }
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+ if modelID != "claude-sonnet-4.6" {
+ t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4.6")
+ }
+}
+
+func TestCreateProviderFromConfig_CodexCLI(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-codex-cli",
+ Model: "codex-cli/codex",
+ }
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+ if modelID != "codex" {
+ t.Errorf("modelID = %q, want %q", modelID, "codex")
+ }
+}
+
+func TestCreateProviderFromConfig_MissingAPIKey(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-no-key",
+ Model: "openai/gpt-4o",
+ }
+
+ _, _, err := CreateProviderFromConfig(cfg)
+ if err == nil {
+ t.Fatal("CreateProviderFromConfig() expected error for missing API key")
+ }
+}
+
+func TestCreateProviderFromConfig_UnknownProtocol(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-unknown",
+ Model: "unknown-protocol/model",
+ APIKey: "test-key",
+ }
+
+ _, _, err := CreateProviderFromConfig(cfg)
+ if err == nil {
+ t.Fatal("CreateProviderFromConfig() expected error for unknown protocol")
+ }
+}
+
+func TestCreateProviderFromConfig_NilConfig(t *testing.T) {
+ _, _, err := CreateProviderFromConfig(nil)
+ if err == nil {
+ t.Fatal("CreateProviderFromConfig(nil) expected error")
+ }
+}
+
+func TestCreateProviderFromConfig_EmptyModel(t *testing.T) {
+ cfg := &config.ModelConfig{
+ ModelName: "test-empty",
+ Model: "",
+ }
+
+ _, _, err := CreateProviderFromConfig(cfg)
+ if err == nil {
+ t.Fatal("CreateProviderFromConfig() expected error for empty model")
+ }
+}
diff --git a/pkg/providers/factory_test.go b/pkg/providers/factory_test.go
new file mode 100644
index 000000000..5680f23b3
--- /dev/null
+++ b/pkg/providers/factory_test.go
@@ -0,0 +1,299 @@
+package providers
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/auth"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestResolveProviderSelection(t *testing.T) {
+ tests := []struct {
+ name string
+ setup func(*config.Config)
+ wantType providerType
+ wantAPIBase string
+ wantProxy string
+ wantErrSubstr string
+ }{
+ {
+ name: "explicit claude-cli provider routes to cli provider type",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Provider = "claude-cli"
+ cfg.Agents.Defaults.Workspace = "/tmp/ws"
+ },
+ wantType: providerTypeClaudeCLI,
+ },
+ {
+ name: "explicit copilot provider routes to github copilot type",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Provider = "copilot"
+ },
+ wantType: providerTypeGitHubCopilot,
+ wantAPIBase: "localhost:4321",
+ },
+ {
+ name: "explicit deepseek provider uses deepseek defaults",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Provider = "deepseek"
+ cfg.Agents.Defaults.Model = "deepseek/deepseek-chat"
+ cfg.Providers.DeepSeek.APIKey = "deepseek-key"
+ cfg.Providers.DeepSeek.Proxy = "http://127.0.0.1:7890"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "https://api.deepseek.com/v1",
+ wantProxy: "http://127.0.0.1:7890",
+ },
+ {
+ name: "explicit shengsuanyun provider uses defaults",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Provider = "shengsuanyun"
+ cfg.Providers.ShengSuanYun.APIKey = "ssy-key"
+ cfg.Providers.ShengSuanYun.Proxy = "http://127.0.0.1:7890"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "https://router.shengsuanyun.com/api/v1",
+ wantProxy: "http://127.0.0.1:7890",
+ },
+ {
+ name: "explicit nvidia provider uses defaults",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Provider = "nvidia"
+ cfg.Providers.Nvidia.APIKey = "nvapi-test"
+ cfg.Providers.Nvidia.Proxy = "http://127.0.0.1:7890"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "https://integrate.api.nvidia.com/v1",
+ wantProxy: "http://127.0.0.1:7890",
+ },
+ {
+ name: "openrouter model uses openrouter defaults",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "openrouter/auto"
+ cfg.Providers.OpenRouter.APIKey = "sk-or-test"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "https://openrouter.ai/api/v1",
+ },
+ {
+ name: "anthropic oauth routes to claude auth provider",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "claude-sonnet-4.6"
+ cfg.Providers.Anthropic.AuthMethod = "oauth"
+ },
+ wantType: providerTypeClaudeAuth,
+ },
+ {
+ name: "openai oauth routes to codex auth provider",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "gpt-4o"
+ cfg.Providers.OpenAI.AuthMethod = "oauth"
+ },
+ wantType: providerTypeCodexAuth,
+ },
+ {
+ name: "openai codex-cli auth routes to codex cli token provider",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "gpt-4o"
+ cfg.Providers.OpenAI.AuthMethod = "codex-cli"
+ },
+ wantType: providerTypeCodexCLIToken,
+ },
+ {
+ name: "explicit codex-code provider routes to codex cli provider type",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Provider = "codex-code"
+ cfg.Agents.Defaults.Workspace = "/tmp/ws"
+ },
+ wantType: providerTypeCodexCLI,
+ },
+ {
+ name: "zhipu model uses zhipu base default",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "glm-4.7"
+ cfg.Providers.Zhipu.APIKey = "zhipu-key"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "https://open.bigmodel.cn/api/paas/v4",
+ },
+ {
+ name: "groq model uses groq base default",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "groq/llama-3.3-70b"
+ cfg.Providers.Groq.APIKey = "gsk-key"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "https://api.groq.com/openai/v1",
+ },
+ {
+ name: "ollama model uses ollama base default",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "ollama/qwen2.5:14b"
+ cfg.Providers.Ollama.APIKey = "ollama-key"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "http://localhost:11434/v1",
+ },
+ {
+ name: "moonshot model keeps proxy and default base",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "moonshot/kimi-k2.5"
+ cfg.Providers.Moonshot.APIKey = "moonshot-key"
+ cfg.Providers.Moonshot.Proxy = "http://127.0.0.1:7890"
+ },
+ wantType: providerTypeHTTPCompat,
+ wantAPIBase: "https://api.moonshot.cn/v1",
+ wantProxy: "http://127.0.0.1:7890",
+ },
+ {
+ name: "missing keys returns model config error",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "custom-model"
+ },
+ wantErrSubstr: "no API key configured for model",
+ },
+ {
+ name: "openrouter prefix without key returns provider key error",
+ setup: func(cfg *config.Config) {
+ cfg.Agents.Defaults.Model = "openrouter/auto"
+ },
+ wantErrSubstr: "no API key configured for provider",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := config.DefaultConfig()
+ tt.setup(cfg)
+
+ got, err := resolveProviderSelection(cfg)
+ if tt.wantErrSubstr != "" {
+ if err == nil {
+ t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr)
+ }
+ if !strings.Contains(err.Error(), tt.wantErrSubstr) {
+ t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErrSubstr)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("resolveProviderSelection() error = %v", err)
+ }
+ if got.providerType != tt.wantType {
+ t.Fatalf("providerType = %v, want %v", got.providerType, tt.wantType)
+ }
+ if tt.wantAPIBase != "" && got.apiBase != tt.wantAPIBase {
+ t.Fatalf("apiBase = %q, want %q", got.apiBase, tt.wantAPIBase)
+ }
+ if tt.wantProxy != "" && got.proxy != tt.wantProxy {
+ t.Fatalf("proxy = %q, want %q", got.proxy, tt.wantProxy)
+ }
+ })
+ }
+}
+
+func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Agents.Defaults.Model = "test-openrouter"
+ cfg.ModelList = []config.ModelConfig{
+ {
+ ModelName: "test-openrouter",
+ Model: "openrouter/auto",
+ APIKey: "sk-or-test",
+ APIBase: "https://openrouter.ai/api/v1",
+ },
+ }
+
+ provider, _, err := CreateProvider(cfg)
+ if err != nil {
+ t.Fatalf("CreateProvider() error = %v", err)
+ }
+
+ if _, ok := provider.(*HTTPProvider); !ok {
+ t.Fatalf("provider type = %T, want *HTTPProvider", provider)
+ }
+}
+
+func TestCreateProviderReturnsCodexCliProviderForCodexCode(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Agents.Defaults.Model = "test-codex"
+ cfg.ModelList = []config.ModelConfig{
+ {
+ ModelName: "test-codex",
+ Model: "codex-cli/codex-model",
+ Workspace: "/tmp/workspace",
+ },
+ }
+
+ provider, _, err := CreateProvider(cfg)
+ if err != nil {
+ t.Fatalf("CreateProvider() error = %v", err)
+ }
+
+ if _, ok := provider.(*CodexCliProvider); !ok {
+ t.Fatalf("provider type = %T, want *CodexCliProvider", provider)
+ }
+}
+
+func TestCreateProviderReturnsClaudeCliProviderForClaudeCli(t *testing.T) {
+ cfg := config.DefaultConfig()
+ cfg.Agents.Defaults.Model = "test-claude-cli"
+ cfg.ModelList = []config.ModelConfig{
+ {
+ ModelName: "test-claude-cli",
+ Model: "claude-cli/claude-sonnet",
+ Workspace: "/tmp/workspace",
+ },
+ }
+
+ provider, _, err := CreateProvider(cfg)
+ if err != nil {
+ t.Fatalf("CreateProvider() error = %v", err)
+ }
+
+ if _, ok := provider.(*ClaudeCliProvider); !ok {
+ t.Fatalf("provider type = %T, want *ClaudeCliProvider", provider)
+ }
+}
+
+func TestCreateProviderReturnsClaudeProviderForAnthropicOAuth(t *testing.T) {
+ originalGetCredential := getCredential
+ t.Cleanup(func() { getCredential = originalGetCredential })
+
+ getCredential = func(provider string) (*auth.AuthCredential, error) {
+ if provider != "anthropic" {
+ t.Fatalf("provider = %q, want anthropic", provider)
+ }
+ return &auth.AuthCredential{
+ AccessToken: "anthropic-token",
+ }, nil
+ }
+
+ cfg := config.DefaultConfig()
+ cfg.Agents.Defaults.Model = "test-claude-oauth"
+ cfg.ModelList = []config.ModelConfig{
+ {
+ ModelName: "test-claude-oauth",
+ Model: "anthropic/claude-sonnet-4.6",
+ AuthMethod: "oauth",
+ },
+ }
+
+ provider, _, err := CreateProvider(cfg)
+ if err != nil {
+ t.Fatalf("CreateProvider() error = %v", err)
+ }
+
+ if _, ok := provider.(*ClaudeProvider); !ok {
+ t.Fatalf("provider type = %T, want *ClaudeProvider", provider)
+ }
+ // TODO: Test custom APIBase when createClaudeAuthProvider supports it
+}
+
+func TestCreateProviderReturnsCodexProviderForOpenAIOAuth(t *testing.T) {
+ // TODO: This test requires openai protocol to support auth_method: "oauth"
+ // which is not yet implemented in the new factory_provider.go
+ t.Skip("OpenAI OAuth via model_list not yet implemented")
+}
diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go
new file mode 100644
index 000000000..9b07f9153
--- /dev/null
+++ b/pkg/providers/fallback.go
@@ -0,0 +1,283 @@
+package providers
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// FallbackChain orchestrates model fallback across multiple candidates.
+type FallbackChain struct {
+ cooldown *CooldownTracker
+}
+
+// FallbackCandidate represents one model/provider to try.
+type FallbackCandidate struct {
+ Provider string
+ Model string
+}
+
+// FallbackResult contains the successful response and metadata about all attempts.
+type FallbackResult struct {
+ Response *LLMResponse
+ Provider string
+ Model string
+ Attempts []FallbackAttempt
+}
+
+// FallbackAttempt records one attempt in the fallback chain.
+type FallbackAttempt struct {
+ Provider string
+ Model string
+ Error error
+ Reason FailoverReason
+ Duration time.Duration
+ Skipped bool // true if skipped due to cooldown
+}
+
+// NewFallbackChain creates a new fallback chain with the given cooldown tracker.
+func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain {
+ return &FallbackChain{cooldown: cooldown}
+}
+
+// ResolveCandidates parses model config into a deduplicated candidate list.
+func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate {
+ seen := make(map[string]bool)
+ var candidates []FallbackCandidate
+
+ addCandidate := func(raw string) {
+ ref := ParseModelRef(raw, defaultProvider)
+ if ref == nil {
+ return
+ }
+ key := ModelKey(ref.Provider, ref.Model)
+ if seen[key] {
+ return
+ }
+ seen[key] = true
+ candidates = append(candidates, FallbackCandidate{
+ Provider: ref.Provider,
+ Model: ref.Model,
+ })
+ }
+
+ // Primary first.
+ addCandidate(cfg.Primary)
+
+ // Then fallbacks.
+ for _, fb := range cfg.Fallbacks {
+ addCandidate(fb)
+ }
+
+ return candidates
+}
+
+// Execute runs the fallback chain for text/chat requests.
+// It tries each candidate in order, respecting cooldowns and error classification.
+//
+// Behavior:
+// - Candidates in cooldown are skipped (logged as skipped attempt).
+// - context.Canceled aborts immediately (user abort, no fallback).
+// - Non-retriable errors (format) abort immediately.
+// - Retriable errors trigger fallback to next candidate.
+// - Success marks provider as good (resets cooldown).
+// - If all fail, returns aggregate error with all attempts.
+func (fc *FallbackChain) Execute(
+ ctx context.Context,
+ candidates []FallbackCandidate,
+ run func(ctx context.Context, provider, model string) (*LLMResponse, error),
+) (*FallbackResult, error) {
+ if len(candidates) == 0 {
+ return nil, fmt.Errorf("fallback: no candidates configured")
+ }
+
+ result := &FallbackResult{
+ Attempts: make([]FallbackAttempt, 0, len(candidates)),
+ }
+
+ for i, candidate := range candidates {
+ // Check context before each attempt.
+ if ctx.Err() == context.Canceled {
+ return nil, context.Canceled
+ }
+
+ // Check cooldown.
+ if !fc.cooldown.IsAvailable(candidate.Provider) {
+ remaining := fc.cooldown.CooldownRemaining(candidate.Provider)
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Skipped: true,
+ Reason: FailoverRateLimit,
+ Error: fmt.Errorf("provider %s in cooldown (%s remaining)", candidate.Provider, remaining.Round(time.Second)),
+ })
+ continue
+ }
+
+ // Execute the run function.
+ start := time.Now()
+ resp, err := run(ctx, candidate.Provider, candidate.Model)
+ elapsed := time.Since(start)
+
+ if err == nil {
+ // Success.
+ fc.cooldown.MarkSuccess(candidate.Provider)
+ result.Response = resp
+ result.Provider = candidate.Provider
+ result.Model = candidate.Model
+ return result, nil
+ }
+
+ // Context cancellation: abort immediately, no fallback.
+ if ctx.Err() == context.Canceled {
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Error: err,
+ Duration: elapsed,
+ })
+ return nil, context.Canceled
+ }
+
+ // Classify the error.
+ failErr := ClassifyError(err, candidate.Provider, candidate.Model)
+
+ if failErr == nil {
+ // Unclassifiable error: do not fallback, return immediately.
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Error: err,
+ Duration: elapsed,
+ })
+ return nil, fmt.Errorf("fallback: unclassified error from %s/%s: %w",
+ candidate.Provider, candidate.Model, err)
+ }
+
+ // Non-retriable error: abort immediately.
+ if !failErr.IsRetriable() {
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Error: failErr,
+ Reason: failErr.Reason,
+ Duration: elapsed,
+ })
+ return nil, failErr
+ }
+
+ // Retriable error: mark failure and continue to next candidate.
+ fc.cooldown.MarkFailure(candidate.Provider, failErr.Reason)
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Error: failErr,
+ Reason: failErr.Reason,
+ Duration: elapsed,
+ })
+
+ // If this was the last candidate, return aggregate error.
+ if i == len(candidates)-1 {
+ return nil, &FallbackExhaustedError{Attempts: result.Attempts}
+ }
+ }
+
+ // All candidates were skipped (all in cooldown).
+ return nil, &FallbackExhaustedError{Attempts: result.Attempts}
+}
+
+// ExecuteImage runs the fallback chain for image/vision requests.
+// Simpler than Execute: no cooldown checks (image endpoints have different rate limits).
+// Image dimension/size errors abort immediately (non-retriable).
+func (fc *FallbackChain) ExecuteImage(
+ ctx context.Context,
+ candidates []FallbackCandidate,
+ run func(ctx context.Context, provider, model string) (*LLMResponse, error),
+) (*FallbackResult, error) {
+ if len(candidates) == 0 {
+ return nil, fmt.Errorf("image fallback: no candidates configured")
+ }
+
+ result := &FallbackResult{
+ Attempts: make([]FallbackAttempt, 0, len(candidates)),
+ }
+
+ for i, candidate := range candidates {
+ if ctx.Err() == context.Canceled {
+ return nil, context.Canceled
+ }
+
+ start := time.Now()
+ resp, err := run(ctx, candidate.Provider, candidate.Model)
+ elapsed := time.Since(start)
+
+ if err == nil {
+ result.Response = resp
+ result.Provider = candidate.Provider
+ result.Model = candidate.Model
+ return result, nil
+ }
+
+ if ctx.Err() == context.Canceled {
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Error: err,
+ Duration: elapsed,
+ })
+ return nil, context.Canceled
+ }
+
+ // Image dimension/size errors are non-retriable.
+ errMsg := strings.ToLower(err.Error())
+ if IsImageDimensionError(errMsg) || IsImageSizeError(errMsg) {
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Error: err,
+ Reason: FailoverFormat,
+ Duration: elapsed,
+ })
+ return nil, &FailoverError{
+ Reason: FailoverFormat,
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Wrapped: err,
+ }
+ }
+
+ // Any other error: record and try next.
+ result.Attempts = append(result.Attempts, FallbackAttempt{
+ Provider: candidate.Provider,
+ Model: candidate.Model,
+ Error: err,
+ Duration: elapsed,
+ })
+
+ if i == len(candidates)-1 {
+ return nil, &FallbackExhaustedError{Attempts: result.Attempts}
+ }
+ }
+
+ return nil, &FallbackExhaustedError{Attempts: result.Attempts}
+}
+
+// FallbackExhaustedError indicates all fallback candidates were tried and failed.
+type FallbackExhaustedError struct {
+ Attempts []FallbackAttempt
+}
+
+func (e *FallbackExhaustedError) Error() string {
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("fallback: all %d candidates failed:", len(e.Attempts)))
+ for i, a := range e.Attempts {
+ if a.Skipped {
+ sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: skipped (cooldown)", i+1, a.Provider, a.Model))
+ } else {
+ sb.WriteString(fmt.Sprintf("\n [%d] %s/%s: %v (reason=%s, %s)",
+ i+1, a.Provider, a.Model, a.Error, a.Reason, a.Duration.Round(time.Millisecond)))
+ }
+ }
+ return sb.String()
+}
diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go
new file mode 100644
index 000000000..ea81e0d48
--- /dev/null
+++ b/pkg/providers/fallback_test.go
@@ -0,0 +1,473 @@
+package providers
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+func makeCandidate(provider, model string) FallbackCandidate {
+ return FallbackCandidate{Provider: provider, Model: model}
+}
+
+func successRun(content string) func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ return func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ return &LLMResponse{Content: content, FinishReason: "stop"}, nil
+ }
+}
+
+func failRun(err error) func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ return func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ return nil, err
+ }
+}
+
+func TestFallback_SingleCandidate_Success(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
+ result, err := fc.Execute(context.Background(), candidates, successRun("hello"))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.Response.Content != "hello" {
+ t.Errorf("content = %q, want hello", result.Response.Content)
+ }
+ if result.Provider != "openai" || result.Model != "gpt-4" {
+ t.Errorf("provider/model = %s/%s, want openai/gpt-4", result.Provider, result.Model)
+ }
+}
+
+func TestFallback_SecondCandidateSuccess(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4"),
+ makeCandidate("anthropic", "claude-opus"),
+ }
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ if attempt == 1 {
+ return nil, errors.New("rate limit exceeded")
+ }
+ return &LLMResponse{Content: "from claude", FinishReason: "stop"}, nil
+ }
+
+ result, err := fc.Execute(context.Background(), candidates, run)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.Provider != "anthropic" {
+ t.Errorf("provider = %q, want anthropic", result.Provider)
+ }
+ if result.Response.Content != "from claude" {
+ t.Errorf("content = %q, want 'from claude'", result.Response.Content)
+ }
+ if len(result.Attempts) != 1 {
+ t.Errorf("attempts = %d, want 1 (failed attempt recorded)", len(result.Attempts))
+ }
+}
+
+func TestFallback_AllFail(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4"),
+ makeCandidate("anthropic", "claude"),
+ makeCandidate("groq", "llama"),
+ }
+
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ return nil, errors.New("rate limit exceeded")
+ }
+
+ _, err := fc.Execute(context.Background(), candidates, run)
+ if err == nil {
+ t.Fatal("expected error when all candidates fail")
+ }
+ var exhausted *FallbackExhaustedError
+ if !errors.As(err, &exhausted) {
+ t.Errorf("expected FallbackExhaustedError, got %T: %v", err, err)
+ }
+ if len(exhausted.Attempts) != 3 {
+ t.Errorf("attempts = %d, want 3", len(exhausted.Attempts))
+ }
+}
+
+func TestFallback_ContextCanceled(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4"),
+ makeCandidate("anthropic", "claude"),
+ }
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ if attempt == 1 {
+ cancel() // cancel context
+ return nil, context.Canceled
+ }
+ t.Error("should not reach second candidate after cancel")
+ return nil, nil
+ }
+
+ _, err := fc.Execute(ctx, candidates, run)
+ if err != context.Canceled {
+ t.Errorf("expected context.Canceled, got %v", err)
+ }
+}
+
+func TestFallback_NonRetriableError(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4"),
+ makeCandidate("anthropic", "claude"),
+ }
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ return nil, errors.New("string should match pattern")
+ }
+
+ _, err := fc.Execute(context.Background(), candidates, run)
+ if err == nil {
+ t.Fatal("expected error for non-retriable")
+ }
+ var fe *FailoverError
+ if !errors.As(err, &fe) {
+ t.Fatalf("expected FailoverError, got %T", err)
+ }
+ if fe.Reason != FailoverFormat {
+ t.Errorf("reason = %q, want format", fe.Reason)
+ }
+ if attempt != 1 {
+ t.Errorf("attempt = %d, want 1 (non-retriable should not try next)", attempt)
+ }
+}
+
+func TestFallback_CooldownSkip(t *testing.T) {
+ now := time.Now()
+ ct, _ := newTestTracker(now)
+ fc := NewFallbackChain(ct)
+
+ // Put openai in cooldown
+ ct.MarkFailure("openai", FailoverRateLimit)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4"),
+ makeCandidate("anthropic", "claude"),
+ }
+
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ if provider == "openai" {
+ t.Error("should not call openai (in cooldown)")
+ }
+ return &LLMResponse{Content: "claude response", FinishReason: "stop"}, nil
+ }
+
+ result, err := fc.Execute(context.Background(), candidates, run)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.Provider != "anthropic" {
+ t.Errorf("provider = %q, want anthropic", result.Provider)
+ }
+ // Should have 1 skipped attempt
+ skipped := 0
+ for _, a := range result.Attempts {
+ if a.Skipped {
+ skipped++
+ }
+ }
+ if skipped != 1 {
+ t.Errorf("skipped = %d, want 1", skipped)
+ }
+}
+
+func TestFallback_AllInCooldown(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ // Put all providers in cooldown
+ ct.MarkFailure("openai", FailoverRateLimit)
+ ct.MarkFailure("anthropic", FailoverBilling)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4"),
+ makeCandidate("anthropic", "claude"),
+ }
+
+ _, err := fc.Execute(context.Background(), candidates,
+ func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ t.Error("should not call any provider (all in cooldown)")
+ return nil, nil
+ })
+
+ if err == nil {
+ t.Fatal("expected error when all in cooldown")
+ }
+ var exhausted *FallbackExhaustedError
+ if !errors.As(err, &exhausted) {
+ t.Fatalf("expected FallbackExhaustedError, got %T", err)
+ }
+}
+
+func TestFallback_NoCandidates(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ _, err := fc.Execute(context.Background(), nil, successRun("ok"))
+ if err == nil {
+ t.Error("expected error for empty candidates")
+ }
+}
+
+func TestFallback_EmptyFallbacks(t *testing.T) {
+ // Single primary, no fallbacks: should work like direct call
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
+ result, err := fc.Execute(context.Background(), candidates, successRun("ok"))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.Response.Content != "ok" {
+ t.Error("expected success with single candidate")
+ }
+}
+
+func TestFallback_UnclassifiedError(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4"),
+ makeCandidate("anthropic", "claude"),
+ }
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ return nil, errors.New("completely unknown internal error")
+ }
+
+ _, err := fc.Execute(context.Background(), candidates, run)
+ if err == nil {
+ t.Fatal("expected error for unclassified error")
+ }
+ if attempt != 1 {
+ t.Errorf("attempt = %d, want 1 (should not fallback on unclassified)", attempt)
+ }
+}
+
+func TestFallback_SuccessResetsCooldown(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")}
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ if attempt == 1 {
+ ct.MarkFailure("openai", FailoverRateLimit) // simulate failure tracked elsewhere
+ }
+ return &LLMResponse{Content: "ok", FinishReason: "stop"}, nil
+ }
+
+ _, err := fc.Execute(context.Background(), candidates, run)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !ct.IsAvailable("openai") {
+ t.Error("success should reset cooldown")
+ }
+}
+
+// --- Image Fallback Tests ---
+
+func TestImageFallback_Success(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4o")}
+ result, err := fc.ExecuteImage(context.Background(), candidates, successRun("image result"))
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.Response.Content != "image result" {
+ t.Error("expected image result")
+ }
+}
+
+func TestImageFallback_DimensionError(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4o"),
+ makeCandidate("anthropic", "claude"),
+ }
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ return nil, errors.New("image dimensions exceed max 4096x4096")
+ }
+
+ _, err := fc.ExecuteImage(context.Background(), candidates, run)
+ if err == nil {
+ t.Fatal("expected error for image dimension error")
+ }
+ if attempt != 1 {
+ t.Errorf("attempt = %d, want 1 (image dimension error should not retry)", attempt)
+ }
+}
+
+func TestImageFallback_SizeError(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4o"),
+ makeCandidate("anthropic", "claude"),
+ }
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ return nil, errors.New("image exceeds 20 mb")
+ }
+
+ _, err := fc.ExecuteImage(context.Background(), candidates, run)
+ if err == nil {
+ t.Fatal("expected error for image size error")
+ }
+ if attempt != 1 {
+ t.Errorf("attempt = %d, want 1 (image size error should not retry)", attempt)
+ }
+}
+
+func TestImageFallback_RetryOnOtherErrors(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ candidates := []FallbackCandidate{
+ makeCandidate("openai", "gpt-4o"),
+ makeCandidate("anthropic", "claude-sonnet"),
+ }
+
+ attempt := 0
+ run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
+ attempt++
+ if attempt == 1 {
+ return nil, errors.New("rate limit exceeded")
+ }
+ return &LLMResponse{Content: "image ok", FinishReason: "stop"}, nil
+ }
+
+ result, err := fc.ExecuteImage(context.Background(), candidates, run)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if result.Provider != "anthropic" {
+ t.Errorf("provider = %q, want anthropic", result.Provider)
+ }
+}
+
+func TestImageFallback_NoCandidates(t *testing.T) {
+ ct := NewCooldownTracker()
+ fc := NewFallbackChain(ct)
+
+ _, err := fc.ExecuteImage(context.Background(), nil, successRun("ok"))
+ if err == nil {
+ t.Error("expected error for empty candidates")
+ }
+}
+
+// --- ResolveCandidates Tests ---
+
+func TestResolveCandidates_Simple(t *testing.T) {
+ cfg := ModelConfig{
+ Primary: "gpt-4",
+ Fallbacks: []string{"anthropic/claude-opus", "groq/llama-3"},
+ }
+
+ candidates := ResolveCandidates(cfg, "openai")
+ if len(candidates) != 3 {
+ t.Fatalf("candidates = %d, want 3", len(candidates))
+ }
+
+ if candidates[0].Provider != "openai" || candidates[0].Model != "gpt-4" {
+ t.Errorf("candidate[0] = %s/%s, want openai/gpt-4", candidates[0].Provider, candidates[0].Model)
+ }
+ if candidates[1].Provider != "anthropic" || candidates[1].Model != "claude-opus" {
+ t.Errorf("candidate[1] = %s/%s, want anthropic/claude-opus", candidates[1].Provider, candidates[1].Model)
+ }
+ if candidates[2].Provider != "groq" || candidates[2].Model != "llama-3" {
+ t.Errorf("candidate[2] = %s/%s, want groq/llama-3", candidates[2].Provider, candidates[2].Model)
+ }
+}
+
+func TestResolveCandidates_Deduplication(t *testing.T) {
+ cfg := ModelConfig{
+ Primary: "openai/gpt-4",
+ Fallbacks: []string{"openai/gpt-4", "anthropic/claude"},
+ }
+
+ candidates := ResolveCandidates(cfg, "default")
+ if len(candidates) != 2 {
+ t.Errorf("candidates = %d, want 2 (duplicate removed)", len(candidates))
+ }
+}
+
+func TestResolveCandidates_EmptyFallbacks(t *testing.T) {
+ cfg := ModelConfig{
+ Primary: "gpt-4",
+ Fallbacks: nil,
+ }
+
+ candidates := ResolveCandidates(cfg, "openai")
+ if len(candidates) != 1 {
+ t.Errorf("candidates = %d, want 1", len(candidates))
+ }
+}
+
+func TestResolveCandidates_EmptyPrimary(t *testing.T) {
+ cfg := ModelConfig{
+ Primary: "",
+ Fallbacks: []string{"anthropic/claude"},
+ }
+
+ candidates := ResolveCandidates(cfg, "openai")
+ if len(candidates) != 1 {
+ t.Errorf("candidates = %d, want 1", len(candidates))
+ }
+}
+
+func TestFallbackExhaustedError_Message(t *testing.T) {
+ e := &FallbackExhaustedError{
+ Attempts: []FallbackAttempt{
+ {Provider: "openai", Model: "gpt-4", Error: errors.New("rate limited"), Reason: FailoverRateLimit, Duration: 500 * time.Millisecond},
+ {Provider: "anthropic", Model: "claude", Skipped: true},
+ },
+ }
+ msg := e.Error()
+ if msg == "" {
+ t.Error("expected non-empty error message")
+ }
+}
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index 17eb6214c..eeaa9690a 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -7,427 +7,31 @@
package providers
import (
- "bytes"
"context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "net/url"
- "strings"
- "time"
- "github.com/sipeed/picoclaw/pkg/auth"
- "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers/openai_compat"
)
type HTTPProvider struct {
- apiKey string
- apiBase string
- httpClient *http.Client
+ delegate *openai_compat.Provider
}
func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
- client := &http.Client{
- Timeout: 120 * time.Second,
- }
-
- if proxy != "" {
- proxyURL, err := url.Parse(proxy)
- if err == nil {
- client.Transport = &http.Transport{
- Proxy: http.ProxyURL(proxyURL),
- }
- }
- }
-
return &HTTPProvider{
- apiKey: apiKey,
- apiBase: strings.TrimRight(apiBase, "/"),
- httpClient: client,
+ delegate: openai_compat.NewProvider(apiKey, apiBase, proxy),
+ }
+}
+
+func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
+ return &HTTPProvider{
+ delegate: openai_compat.NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField),
}
}
func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
- if p.apiBase == "" {
- return nil, fmt.Errorf("API base not configured")
- }
-
- // Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5)
- if idx := strings.Index(model, "/"); idx != -1 {
- prefix := model[:idx]
- if prefix == "moonshot" || prefix == "nvidia" {
- model = model[idx+1:]
- }
- }
-
- requestBody := map[string]interface{}{
- "model": model,
- "messages": messages,
- }
-
- if len(tools) > 0 {
- requestBody["tools"] = tools
- requestBody["tool_choice"] = "auto"
- }
-
- if maxTokens, ok := options["max_tokens"].(int); ok {
- lowerModel := strings.ToLower(model)
- if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") {
- requestBody["max_completion_tokens"] = maxTokens
- } else {
- requestBody["max_tokens"] = maxTokens
- }
- }
-
- if temperature, ok := options["temperature"].(float64); ok {
- lowerModel := strings.ToLower(model)
- // Kimi k2 models only support temperature=1
- if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
- requestBody["temperature"] = 1.0
- } else {
- requestBody["temperature"] = temperature
- }
- }
-
- jsonData, err := json.Marshal(requestBody)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal request: %w", err)
- }
-
- req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
- if err != nil {
- return nil, fmt.Errorf("failed to create request: %w", err)
- }
-
- req.Header.Set("Content-Type", "application/json")
- if p.apiKey != "" {
- req.Header.Set("Authorization", "Bearer "+p.apiKey)
- }
-
- resp, err := p.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to send request: %w", err)
- }
- defer resp.Body.Close()
-
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response: %w", err)
- }
-
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
- }
-
- return p.parseResponse(body)
-}
-
-func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) {
- var apiResponse struct {
- Choices []struct {
- Message struct {
- Content string `json:"content"`
- ToolCalls []struct {
- ID string `json:"id"`
- Type string `json:"type"`
- Function *struct {
- Name string `json:"name"`
- Arguments string `json:"arguments"`
- } `json:"function"`
- } `json:"tool_calls"`
- } `json:"message"`
- FinishReason string `json:"finish_reason"`
- } `json:"choices"`
- Usage *UsageInfo `json:"usage"`
- }
-
- if err := json.Unmarshal(body, &apiResponse); err != nil {
- return nil, fmt.Errorf("failed to unmarshal response: %w", err)
- }
-
- if len(apiResponse.Choices) == 0 {
- return &LLMResponse{
- Content: "",
- FinishReason: "stop",
- }, nil
- }
-
- choice := apiResponse.Choices[0]
-
- toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
- for _, tc := range choice.Message.ToolCalls {
- arguments := make(map[string]interface{})
- name := ""
-
- // Handle OpenAI format with nested function object
- if tc.Type == "function" && tc.Function != nil {
- name = tc.Function.Name
- if tc.Function.Arguments != "" {
- if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
- arguments["raw"] = tc.Function.Arguments
- }
- }
- } else if tc.Function != nil {
- // Legacy format without type field
- name = tc.Function.Name
- if tc.Function.Arguments != "" {
- if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
- arguments["raw"] = tc.Function.Arguments
- }
- }
- }
-
- toolCalls = append(toolCalls, ToolCall{
- ID: tc.ID,
- Name: name,
- Arguments: arguments,
- })
- }
-
- return &LLMResponse{
- Content: choice.Message.Content,
- ToolCalls: toolCalls,
- FinishReason: choice.FinishReason,
- Usage: apiResponse.Usage,
- }, nil
+ return p.delegate.Chat(ctx, messages, tools, model, options)
}
func (p *HTTPProvider) GetDefaultModel() string {
return ""
}
-
-func createClaudeAuthProvider() (LLMProvider, error) {
- cred, err := auth.GetCredential("anthropic")
- if err != nil {
- return nil, fmt.Errorf("loading auth credentials: %w", err)
- }
- if cred == nil {
- return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic")
- }
- return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil
-}
-
-func createCodexAuthProvider() (LLMProvider, error) {
- cred, err := auth.GetCredential("openai")
- if err != nil {
- return nil, fmt.Errorf("loading auth credentials: %w", err)
- }
- if cred == nil {
- return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai")
- }
- return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil
-}
-
-func CreateProvider(cfg *config.Config) (LLMProvider, error) {
- model := cfg.Agents.Defaults.Model
- providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
-
- var apiKey, apiBase, proxy string
-
- lowerModel := strings.ToLower(model)
-
- // First, try to use explicitly configured provider
- if providerName != "" {
- switch providerName {
- case "groq":
- if cfg.Providers.Groq.APIKey != "" {
- apiKey = cfg.Providers.Groq.APIKey
- apiBase = cfg.Providers.Groq.APIBase
- if apiBase == "" {
- apiBase = "https://api.groq.com/openai/v1"
- }
- }
- case "openai", "gpt":
- if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" {
- if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
- return createCodexAuthProvider()
- }
- apiKey = cfg.Providers.OpenAI.APIKey
- apiBase = cfg.Providers.OpenAI.APIBase
- if apiBase == "" {
- apiBase = "https://api.openai.com/v1"
- }
- }
- case "anthropic", "claude":
- if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" {
- if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
- return createClaudeAuthProvider()
- }
- apiKey = cfg.Providers.Anthropic.APIKey
- apiBase = cfg.Providers.Anthropic.APIBase
- if apiBase == "" {
- apiBase = "https://api.anthropic.com/v1"
- }
- }
- case "openrouter":
- if cfg.Providers.OpenRouter.APIKey != "" {
- apiKey = cfg.Providers.OpenRouter.APIKey
- if cfg.Providers.OpenRouter.APIBase != "" {
- apiBase = cfg.Providers.OpenRouter.APIBase
- } else {
- apiBase = "https://openrouter.ai/api/v1"
- }
- }
- case "zhipu", "glm":
- if cfg.Providers.Zhipu.APIKey != "" {
- apiKey = cfg.Providers.Zhipu.APIKey
- apiBase = cfg.Providers.Zhipu.APIBase
- if apiBase == "" {
- apiBase = "https://open.bigmodel.cn/api/paas/v4"
- }
- }
- case "gemini", "google":
- if cfg.Providers.Gemini.APIKey != "" {
- apiKey = cfg.Providers.Gemini.APIKey
- apiBase = cfg.Providers.Gemini.APIBase
- if apiBase == "" {
- apiBase = "https://generativelanguage.googleapis.com/v1beta"
- }
- }
- case "vllm":
- if cfg.Providers.VLLM.APIBase != "" {
- apiKey = cfg.Providers.VLLM.APIKey
- apiBase = cfg.Providers.VLLM.APIBase
- }
- case "shengsuanyun":
- if cfg.Providers.ShengSuanYun.APIKey != "" {
- apiKey = cfg.Providers.ShengSuanYun.APIKey
- apiBase = cfg.Providers.ShengSuanYun.APIBase
- if apiBase == "" {
- apiBase = "https://router.shengsuanyun.com/api/v1"
- }
- }
- case "claude-cli", "claudecode", "claude-code":
- workspace := cfg.Agents.Defaults.Workspace
- if workspace == "" {
- workspace = "."
- }
- return NewClaudeCliProvider(workspace), nil
- case "deepseek":
- if cfg.Providers.DeepSeek.APIKey != "" {
- apiKey = cfg.Providers.DeepSeek.APIKey
- apiBase = cfg.Providers.DeepSeek.APIBase
- if apiBase == "" {
- apiBase = "https://api.deepseek.com/v1"
- }
- if model != "deepseek-chat" && model != "deepseek-reasoner" {
- model = "deepseek-chat"
- }
- }
- case "github_copilot", "copilot":
- if cfg.Providers.GitHubCopilot.APIBase != "" {
- apiBase = cfg.Providers.GitHubCopilot.APIBase
- } else {
- apiBase = "localhost:4321"
- }
- return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model)
-
- }
-
- }
-
- // Fallback: detect provider from model name
- if apiKey == "" && apiBase == "" {
- switch {
- case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "":
- apiKey = cfg.Providers.Moonshot.APIKey
- apiBase = cfg.Providers.Moonshot.APIBase
- proxy = cfg.Providers.Moonshot.Proxy
- if apiBase == "" {
- apiBase = "https://api.moonshot.cn/v1"
- }
-
- case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"):
- apiKey = cfg.Providers.OpenRouter.APIKey
- proxy = cfg.Providers.OpenRouter.Proxy
- if cfg.Providers.OpenRouter.APIBase != "" {
- apiBase = cfg.Providers.OpenRouter.APIBase
- } else {
- apiBase = "https://openrouter.ai/api/v1"
- }
-
- case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
- if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
- return createClaudeAuthProvider()
- }
- apiKey = cfg.Providers.Anthropic.APIKey
- apiBase = cfg.Providers.Anthropic.APIBase
- proxy = cfg.Providers.Anthropic.Proxy
- if apiBase == "" {
- apiBase = "https://api.anthropic.com/v1"
- }
-
- case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
- if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
- return createCodexAuthProvider()
- }
- apiKey = cfg.Providers.OpenAI.APIKey
- apiBase = cfg.Providers.OpenAI.APIBase
- proxy = cfg.Providers.OpenAI.Proxy
- if apiBase == "" {
- apiBase = "https://api.openai.com/v1"
- }
-
- case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
- apiKey = cfg.Providers.Gemini.APIKey
- apiBase = cfg.Providers.Gemini.APIBase
- proxy = cfg.Providers.Gemini.Proxy
- if apiBase == "" {
- apiBase = "https://generativelanguage.googleapis.com/v1beta"
- }
-
- case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "":
- apiKey = cfg.Providers.Zhipu.APIKey
- apiBase = cfg.Providers.Zhipu.APIBase
- proxy = cfg.Providers.Zhipu.Proxy
- if apiBase == "" {
- apiBase = "https://open.bigmodel.cn/api/paas/v4"
- }
-
- case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
- apiKey = cfg.Providers.Groq.APIKey
- apiBase = cfg.Providers.Groq.APIBase
- proxy = cfg.Providers.Groq.Proxy
- if apiBase == "" {
- apiBase = "https://api.groq.com/openai/v1"
- }
-
- case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "":
- apiKey = cfg.Providers.Nvidia.APIKey
- apiBase = cfg.Providers.Nvidia.APIBase
- proxy = cfg.Providers.Nvidia.Proxy
- if apiBase == "" {
- apiBase = "https://integrate.api.nvidia.com/v1"
- }
-
- case cfg.Providers.VLLM.APIBase != "":
- apiKey = cfg.Providers.VLLM.APIKey
- apiBase = cfg.Providers.VLLM.APIBase
- proxy = cfg.Providers.VLLM.Proxy
-
- default:
- if cfg.Providers.OpenRouter.APIKey != "" {
- apiKey = cfg.Providers.OpenRouter.APIKey
- proxy = cfg.Providers.OpenRouter.Proxy
- if cfg.Providers.OpenRouter.APIBase != "" {
- apiBase = cfg.Providers.OpenRouter.APIBase
- } else {
- apiBase = "https://openrouter.ai/api/v1"
- }
- } else {
- return nil, fmt.Errorf("no API key configured for model: %s", model)
- }
- }
- }
-
- if apiKey == "" && !strings.HasPrefix(model, "bedrock/") {
- return nil, fmt.Errorf("no API key configured for provider (model: %s)", model)
- }
-
- if apiBase == "" {
- return nil, fmt.Errorf("no API base configured for provider (model: %s)", model)
- }
-
- return NewHTTPProvider(apiKey, apiBase, proxy), nil
-}
diff --git a/pkg/providers/legacy_provider.go b/pkg/providers/legacy_provider.go
new file mode 100644
index 000000000..eb13cec65
--- /dev/null
+++ b/pkg/providers/legacy_provider.go
@@ -0,0 +1,49 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package providers
+
+import (
+ "fmt"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+// CreateProvider creates a provider based on the configuration.
+// It uses the model_list configuration (new format) to create providers.
+// The old providers config is automatically converted to model_list during config loading.
+// Returns the provider, the model ID to use, and any error.
+func CreateProvider(cfg *config.Config) (LLMProvider, string, error) {
+ model := cfg.Agents.Defaults.Model
+
+ // Ensure model_list is populated (should be done by LoadConfig, but handle edge cases)
+ if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
+ cfg.ModelList = config.ConvertProvidersToModelList(cfg)
+ }
+
+ // Must have model_list at this point
+ if len(cfg.ModelList) == 0 {
+ return nil, "", fmt.Errorf("no providers configured. Please add entries to model_list in your config")
+ }
+
+ // Get model config from model_list
+ modelCfg, err := cfg.GetModelConfig(model)
+ if err != nil {
+ return nil, "", fmt.Errorf("model %q not found in model_list: %w", model, err)
+ }
+
+ // Inject global workspace if not set in model config
+ if modelCfg.Workspace == "" {
+ modelCfg.Workspace = cfg.WorkspacePath()
+ }
+
+ // Use factory to create provider
+ provider, modelID, err := CreateProviderFromConfig(modelCfg)
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to create provider for model %q: %w", model, err)
+ }
+
+ return provider, modelID, nil
+}
diff --git a/pkg/providers/model_ref.go b/pkg/providers/model_ref.go
new file mode 100644
index 000000000..0d1b02d16
--- /dev/null
+++ b/pkg/providers/model_ref.go
@@ -0,0 +1,64 @@
+package providers
+
+import "strings"
+
+// ModelRef represents a parsed model reference with provider and model name.
+type ModelRef struct {
+ Provider string
+ Model string
+}
+
+// ParseModelRef parses "anthropic/claude-opus" into {Provider: "anthropic", Model: "claude-opus"}.
+// If no slash present, uses defaultProvider.
+// Returns nil for empty input.
+func ParseModelRef(raw string, defaultProvider string) *ModelRef {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil
+ }
+
+ if idx := strings.Index(raw, "/"); idx > 0 {
+ provider := NormalizeProvider(raw[:idx])
+ model := strings.TrimSpace(raw[idx+1:])
+ if model == "" {
+ return nil
+ }
+ return &ModelRef{Provider: provider, Model: model}
+ }
+
+ return &ModelRef{
+ Provider: NormalizeProvider(defaultProvider),
+ Model: raw,
+ }
+}
+
+// NormalizeProvider normalizes provider identifiers to canonical form.
+func NormalizeProvider(provider string) string {
+ p := strings.ToLower(strings.TrimSpace(provider))
+
+ switch p {
+ case "z.ai", "z-ai":
+ return "zai"
+ case "opencode-zen":
+ return "opencode"
+ case "qwen":
+ return "qwen-portal"
+ case "kimi-code":
+ return "kimi-coding"
+ case "gpt":
+ return "openai"
+ case "claude":
+ return "anthropic"
+ case "glm":
+ return "zhipu"
+ case "google":
+ return "gemini"
+ }
+
+ return p
+}
+
+// ModelKey returns a canonical "provider/model" key for deduplication.
+func ModelKey(provider, model string) string {
+ return NormalizeProvider(provider) + "/" + strings.ToLower(strings.TrimSpace(model))
+}
diff --git a/pkg/providers/model_ref_test.go b/pkg/providers/model_ref_test.go
new file mode 100644
index 000000000..6dd25167f
--- /dev/null
+++ b/pkg/providers/model_ref_test.go
@@ -0,0 +1,125 @@
+package providers
+
+import "testing"
+
+func TestParseModelRef_WithSlash(t *testing.T) {
+ ref := ParseModelRef("anthropic/claude-opus", "openai")
+ if ref == nil {
+ t.Fatal("expected non-nil ref")
+ }
+ if ref.Provider != "anthropic" {
+ t.Errorf("provider = %q, want anthropic", ref.Provider)
+ }
+ if ref.Model != "claude-opus" {
+ t.Errorf("model = %q, want claude-opus", ref.Model)
+ }
+}
+
+func TestParseModelRef_WithoutSlash(t *testing.T) {
+ ref := ParseModelRef("gpt-4", "openai")
+ if ref == nil {
+ t.Fatal("expected non-nil ref")
+ }
+ if ref.Provider != "openai" {
+ t.Errorf("provider = %q, want openai", ref.Provider)
+ }
+ if ref.Model != "gpt-4" {
+ t.Errorf("model = %q, want gpt-4", ref.Model)
+ }
+}
+
+func TestParseModelRef_Empty(t *testing.T) {
+ ref := ParseModelRef("", "openai")
+ if ref != nil {
+ t.Errorf("expected nil for empty string, got %+v", ref)
+ }
+}
+
+func TestParseModelRef_EmptyModelAfterSlash(t *testing.T) {
+ ref := ParseModelRef("openai/", "default")
+ if ref != nil {
+ t.Errorf("expected nil for empty model, got %+v", ref)
+ }
+}
+
+func TestParseModelRef_WhitespaceHandling(t *testing.T) {
+ ref := ParseModelRef(" anthropic / claude-opus ", "openai")
+ if ref == nil {
+ t.Fatal("expected non-nil ref")
+ }
+ if ref.Provider != "anthropic" {
+ t.Errorf("provider = %q, want anthropic", ref.Provider)
+ }
+ if ref.Model != "claude-opus" {
+ t.Errorf("model = %q, want claude-opus", ref.Model)
+ }
+}
+
+func TestNormalizeProvider(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ {"OpenAI", "openai"},
+ {"ANTHROPIC", "anthropic"},
+ {"z.ai", "zai"},
+ {"z-ai", "zai"},
+ {"Z.AI", "zai"},
+ {"opencode-zen", "opencode"},
+ {"qwen", "qwen-portal"},
+ {"kimi-code", "kimi-coding"},
+ {"gpt", "openai"},
+ {"claude", "anthropic"},
+ {"glm", "zhipu"},
+ {"google", "gemini"},
+ {"groq", "groq"},
+ {"", ""},
+ }
+
+ for _, tt := range tests {
+ got := NormalizeProvider(tt.input)
+ if got != tt.want {
+ t.Errorf("NormalizeProvider(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ }
+}
+
+func TestModelKey(t *testing.T) {
+ tests := []struct {
+ provider string
+ model string
+ want string
+ }{
+ {"openai", "gpt-4", "openai/gpt-4"},
+ {"Anthropic", "Claude-Opus", "anthropic/claude-opus"},
+ {"claude", "sonnet", "anthropic/sonnet"},
+ {"z.ai", "Model-X", "zai/model-x"},
+ }
+
+ for _, tt := range tests {
+ got := ModelKey(tt.provider, tt.model)
+ if got != tt.want {
+ t.Errorf("ModelKey(%q, %q) = %q, want %q", tt.provider, tt.model, got, tt.want)
+ }
+ }
+}
+
+func TestParseModelRef_ProviderNormalization(t *testing.T) {
+ ref := ParseModelRef("Z.AI/model-x", "default")
+ if ref == nil {
+ t.Fatal("expected non-nil ref")
+ }
+ if ref.Provider != "zai" {
+ t.Errorf("provider = %q, want zai", ref.Provider)
+ }
+}
+
+func TestParseModelRef_DefaultProviderNormalization(t *testing.T) {
+ ref := ParseModelRef("gpt-4o", "GPT")
+ if ref == nil {
+ t.Fatal("expected non-nil ref")
+ }
+ if ref.Provider != "openai" {
+ t.Errorf("provider = %q, want openai (normalized from GPT)", ref.Provider)
+ }
+}
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
new file mode 100644
index 000000000..6bc43a470
--- /dev/null
+++ b/pkg/providers/openai_compat/provider.go
@@ -0,0 +1,269 @@
+package openai_compat
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
+)
+
+type ToolCall = protocoltypes.ToolCall
+type FunctionCall = protocoltypes.FunctionCall
+type LLMResponse = protocoltypes.LLMResponse
+type UsageInfo = protocoltypes.UsageInfo
+type Message = protocoltypes.Message
+type ToolDefinition = protocoltypes.ToolDefinition
+type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
+type ExtraContent = protocoltypes.ExtraContent
+type GoogleExtra = protocoltypes.GoogleExtra
+
+type Provider struct {
+ apiKey string
+ apiBase string
+ maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
+ httpClient *http.Client
+}
+
+func NewProvider(apiKey, apiBase, proxy string) *Provider {
+ return NewProviderWithMaxTokensField(apiKey, apiBase, proxy, "")
+}
+
+func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
+ client := &http.Client{
+ Timeout: 120 * time.Second,
+ }
+
+ if proxy != "" {
+ parsed, err := url.Parse(proxy)
+ if err == nil {
+ client.Transport = &http.Transport{
+ Proxy: http.ProxyURL(parsed),
+ }
+ } else {
+ log.Printf("openai_compat: invalid proxy URL %q: %v", proxy, err)
+ }
+ }
+
+ return &Provider{
+ apiKey: apiKey,
+ apiBase: strings.TrimRight(apiBase, "/"),
+ maxTokensField: maxTokensField,
+ httpClient: client,
+ }
+}
+
+func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+ if p.apiBase == "" {
+ return nil, fmt.Errorf("API base not configured")
+ }
+
+ model = normalizeModel(model, p.apiBase)
+
+ requestBody := map[string]interface{}{
+ "model": model,
+ "messages": messages,
+ }
+
+ if len(tools) > 0 {
+ requestBody["tools"] = tools
+ requestBody["tool_choice"] = "auto"
+ }
+
+ if maxTokens, ok := asInt(options["max_tokens"]); ok {
+ // Use configured maxTokensField if specified, otherwise fallback to model-based detection
+ fieldName := p.maxTokensField
+ if fieldName == "" {
+ // Fallback: detect from model name for backward compatibility
+ lowerModel := strings.ToLower(model)
+ if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") || strings.Contains(lowerModel, "gpt-5") {
+ fieldName = "max_completion_tokens"
+ } else {
+ fieldName = "max_tokens"
+ }
+ }
+ requestBody[fieldName] = maxTokens
+ }
+
+ if temperature, ok := asFloat(options["temperature"]); ok {
+ lowerModel := strings.ToLower(model)
+ // Kimi k2 models only support temperature=1.
+ if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
+ requestBody["temperature"] = 1.0
+ } else {
+ requestBody["temperature"] = temperature
+ }
+ }
+
+ jsonData, err := json.Marshal(requestBody)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ if p.apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+p.apiKey)
+ }
+
+ resp, err := p.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body))
+ }
+
+ return parseResponse(body)
+}
+
+func parseResponse(body []byte) (*LLMResponse, error) {
+ var apiResponse struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ ToolCalls []struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Function *struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ } `json:"function"`
+ ExtraContent *struct {
+ Google *struct {
+ ThoughtSignature string `json:"thought_signature"`
+ } `json:"google"`
+ } `json:"extra_content"`
+ } `json:"tool_calls"`
+ } `json:"message"`
+ FinishReason string `json:"finish_reason"`
+ } `json:"choices"`
+ Usage *UsageInfo `json:"usage"`
+ }
+
+ if err := json.Unmarshal(body, &apiResponse); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal response: %w", err)
+ }
+
+ if len(apiResponse.Choices) == 0 {
+ return &LLMResponse{
+ Content: "",
+ FinishReason: "stop",
+ }, nil
+ }
+
+ choice := apiResponse.Choices[0]
+ toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
+ for _, tc := range choice.Message.ToolCalls {
+ arguments := make(map[string]interface{})
+ name := ""
+
+ // Extract thought_signature from Gemini/Google-specific extra content
+ thoughtSignature := ""
+ if tc.ExtraContent != nil && tc.ExtraContent.Google != nil {
+ thoughtSignature = tc.ExtraContent.Google.ThoughtSignature
+ }
+
+ if tc.Function != nil {
+ name = tc.Function.Name
+ if tc.Function.Arguments != "" {
+ if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
+ log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err)
+ arguments["raw"] = tc.Function.Arguments
+ }
+ }
+ }
+
+ // Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence
+ toolCall := ToolCall{
+ ID: tc.ID,
+ Name: name,
+ Arguments: arguments,
+ ThoughtSignature: thoughtSignature,
+ }
+
+ if thoughtSignature != "" {
+ toolCall.ExtraContent = &ExtraContent{
+ Google: &GoogleExtra{
+ ThoughtSignature: thoughtSignature,
+ },
+ }
+ }
+
+ toolCalls = append(toolCalls, toolCall)
+ }
+
+ return &LLMResponse{
+ Content: choice.Message.Content,
+ ToolCalls: toolCalls,
+ FinishReason: choice.FinishReason,
+ Usage: apiResponse.Usage,
+ }, nil
+}
+
+func normalizeModel(model, apiBase string) string {
+ idx := strings.Index(model, "/")
+ if idx == -1 {
+ return model
+ }
+
+ if strings.Contains(strings.ToLower(apiBase), "openrouter.ai") {
+ return model
+ }
+
+ prefix := strings.ToLower(model[:idx])
+ switch prefix {
+ case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu":
+ return model[idx+1:]
+ default:
+ return model
+ }
+}
+
+func asInt(v interface{}) (int, bool) {
+ switch val := v.(type) {
+ case int:
+ return val, true
+ case int64:
+ return int(val), true
+ case float64:
+ return int(val), true
+ case float32:
+ return int(val), true
+ default:
+ return 0, false
+ }
+}
+
+func asFloat(v interface{}) (float64, bool) {
+ switch val := v.(type) {
+ case float64:
+ return val, true
+ case float32:
+ return float64(val), true
+ case int:
+ return float64(val), true
+ case int64:
+ return float64(val), true
+ default:
+ return 0, false
+ }
+}
diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go
new file mode 100644
index 000000000..94779b39c
--- /dev/null
+++ b/pkg/providers/openai_compat/provider_test.go
@@ -0,0 +1,277 @@
+package openai_compat
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "testing"
+)
+
+func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
+ var requestBody map[string]interface{}
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/chat/completions" {
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ resp := map[string]interface{}{
+ "choices": []map[string]interface{}{
+ {
+ "message": map[string]interface{}{"content": "ok"},
+ "finish_reason": "stop",
+ },
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL, "")
+ _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "glm-4.7", map[string]interface{}{"max_tokens": 1234})
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+
+ if _, ok := requestBody["max_completion_tokens"]; !ok {
+ t.Fatalf("expected max_completion_tokens in request body")
+ }
+ if _, ok := requestBody["max_tokens"]; ok {
+ t.Fatalf("did not expect max_tokens key for glm model")
+ }
+}
+
+func TestProviderChat_ParsesToolCalls(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := map[string]interface{}{
+ "choices": []map[string]interface{}{
+ {
+ "message": map[string]interface{}{
+ "content": "",
+ "tool_calls": []map[string]interface{}{
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": map[string]interface{}{
+ "name": "get_weather",
+ "arguments": "{\"city\":\"SF\"}",
+ },
+ },
+ },
+ },
+ "finish_reason": "tool_calls",
+ },
+ },
+ "usage": map[string]interface{}{
+ "prompt_tokens": 10,
+ "completion_tokens": 5,
+ "total_tokens": 15,
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL, "")
+ out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+ if len(out.ToolCalls) != 1 {
+ t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
+ }
+ if out.ToolCalls[0].Name != "get_weather" {
+ t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather")
+ }
+ if out.ToolCalls[0].Arguments["city"] != "SF" {
+ t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"])
+ }
+}
+
+func TestProviderChat_HTTPError(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "bad request", http.StatusBadRequest)
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL, "")
+ _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+}
+
+func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) {
+ var requestBody map[string]interface{}
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ resp := map[string]interface{}{
+ "choices": []map[string]interface{}{
+ {
+ "message": map[string]interface{}{"content": "ok"},
+ "finish_reason": "stop",
+ },
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL, "")
+ _, err := p.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hi"}},
+ nil,
+ "moonshot/kimi-k2.5",
+ map[string]interface{}{"temperature": 0.3},
+ )
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+
+ if requestBody["model"] != "kimi-k2.5" {
+ t.Fatalf("model = %v, want kimi-k2.5", requestBody["model"])
+ }
+ if requestBody["temperature"] != 1.0 {
+ t.Fatalf("temperature = %v, want 1.0", requestBody["temperature"])
+ }
+}
+
+func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ wantModel string
+ }{
+ {
+ name: "strips groq prefix and keeps nested model",
+ input: "groq/openai/gpt-oss-120b",
+ wantModel: "openai/gpt-oss-120b",
+ },
+ {
+ name: "strips ollama prefix",
+ input: "ollama/qwen2.5:14b",
+ wantModel: "qwen2.5:14b",
+ },
+ {
+ name: "strips deepseek prefix",
+ input: "deepseek/deepseek-chat",
+ wantModel: "deepseek-chat",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var requestBody map[string]interface{}
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ resp := map[string]interface{}{
+ "choices": []map[string]interface{}{
+ {
+ "message": map[string]interface{}{"content": "ok"},
+ "finish_reason": "stop",
+ },
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL, "")
+ _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, tt.input, nil)
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+
+ if requestBody["model"] != tt.wantModel {
+ t.Fatalf("model = %v, want %s", requestBody["model"], tt.wantModel)
+ }
+ })
+ }
+}
+
+func TestProvider_ProxyConfigured(t *testing.T) {
+ proxyURL := "http://127.0.0.1:8080"
+ p := NewProvider("key", "https://example.com", proxyURL)
+
+ transport, ok := p.httpClient.Transport.(*http.Transport)
+ if !ok || transport == nil {
+ t.Fatalf("expected http transport with proxy, got %T", p.httpClient.Transport)
+ }
+
+ req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}}
+ gotProxy, err := transport.Proxy(req)
+ if err != nil {
+ t.Fatalf("proxy function returned error: %v", err)
+ }
+ if gotProxy == nil || gotProxy.String() != proxyURL {
+ t.Fatalf("proxy = %v, want %s", gotProxy, proxyURL)
+ }
+}
+
+func TestProviderChat_AcceptsNumericOptionTypes(t *testing.T) {
+ var requestBody map[string]interface{}
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ resp := map[string]interface{}{
+ "choices": []map[string]interface{}{
+ {
+ "message": map[string]interface{}{"content": "ok"},
+ "finish_reason": "stop",
+ },
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL, "")
+ _, err := p.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hi"}},
+ nil,
+ "gpt-4o",
+ map[string]interface{}{"max_tokens": float64(512), "temperature": 1},
+ )
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+
+ if requestBody["max_tokens"] != float64(512) {
+ t.Fatalf("max_tokens = %v, want 512", requestBody["max_tokens"])
+ }
+ if requestBody["temperature"] != float64(1) {
+ t.Fatalf("temperature = %v, want 1", requestBody["temperature"])
+ }
+}
+
+func TestNormalizeModel_UsesAPIBase(t *testing.T) {
+ if got := normalizeModel("deepseek/deepseek-chat", "https://api.deepseek.com/v1"); got != "deepseek-chat" {
+ t.Fatalf("normalizeModel(deepseek) = %q, want %q", got, "deepseek-chat")
+ }
+ if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" {
+ t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
+ }
+}
diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go
new file mode 100644
index 000000000..b7e7062b9
--- /dev/null
+++ b/pkg/providers/protocoltypes/types.go
@@ -0,0 +1,56 @@
+package protocoltypes
+
+type ToolCall struct {
+ ID string `json:"id"`
+ Type string `json:"type,omitempty"`
+ Function *FunctionCall `json:"function,omitempty"`
+ Name string `json:"name,omitempty"`
+ Arguments map[string]interface{} `json:"arguments,omitempty"`
+ ThoughtSignature string `json:"-"` // Internal use only
+ ExtraContent *ExtraContent `json:"extra_content,omitempty"`
+}
+
+type ExtraContent struct {
+ Google *GoogleExtra `json:"google,omitempty"`
+}
+
+type GoogleExtra struct {
+ ThoughtSignature string `json:"thought_signature,omitempty"`
+}
+
+type FunctionCall struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ ThoughtSignature string `json:"thought_signature,omitempty"`
+}
+
+type LLMResponse struct {
+ Content string `json:"content"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ FinishReason string `json:"finish_reason"`
+ Usage *UsageInfo `json:"usage,omitempty"`
+}
+
+type UsageInfo struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+}
+
+type Message struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ ToolCallID string `json:"tool_call_id,omitempty"`
+}
+
+type ToolDefinition struct {
+ Type string `json:"type"`
+ Function ToolFunctionDefinition `json:"function"`
+}
+
+type ToolFunctionDefinition struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters map[string]interface{} `json:"parameters"`
+}
diff --git a/pkg/providers/tool_call_extract.go b/pkg/providers/tool_call_extract.go
new file mode 100644
index 000000000..97a219283
--- /dev/null
+++ b/pkg/providers/tool_call_extract.go
@@ -0,0 +1,72 @@
+package providers
+
+import (
+ "encoding/json"
+ "strings"
+)
+
+// extractToolCallsFromText parses tool call JSON from response text.
+// Both ClaudeCliProvider and CodexCliProvider use this to extract
+// tool calls that the model outputs in its response text.
+func extractToolCallsFromText(text string) []ToolCall {
+ start := strings.Index(text, `{"tool_calls"`)
+ if start == -1 {
+ return nil
+ }
+
+ end := findMatchingBrace(text, start)
+ if end == start {
+ return nil
+ }
+
+ jsonStr := text[start:end]
+
+ var wrapper struct {
+ ToolCalls []struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Function struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ } `json:"function"`
+ } `json:"tool_calls"`
+ }
+
+ if err := json.Unmarshal([]byte(jsonStr), &wrapper); err != nil {
+ return nil
+ }
+
+ var result []ToolCall
+ for _, tc := range wrapper.ToolCalls {
+ var args map[string]interface{}
+ json.Unmarshal([]byte(tc.Function.Arguments), &args)
+
+ result = append(result, ToolCall{
+ ID: tc.ID,
+ Type: tc.Type,
+ Name: tc.Function.Name,
+ Arguments: args,
+ Function: &FunctionCall{
+ Name: tc.Function.Name,
+ Arguments: tc.Function.Arguments,
+ },
+ })
+ }
+
+ return result
+}
+
+// stripToolCallsFromText removes tool call JSON from response text.
+func stripToolCallsFromText(text string) string {
+ start := strings.Index(text, `{"tool_calls"`)
+ if start == -1 {
+ return text
+ }
+
+ end := findMatchingBrace(text, start)
+ if end == start {
+ return text
+ }
+
+ return strings.TrimSpace(text[:start] + text[end:])
+}
diff --git a/pkg/providers/toolcall_utils.go b/pkg/providers/toolcall_utils.go
new file mode 100644
index 000000000..c7c35ef42
--- /dev/null
+++ b/pkg/providers/toolcall_utils.go
@@ -0,0 +1,54 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package providers
+
+import "encoding/json"
+
+// NormalizeToolCall normalizes a ToolCall to ensure all fields are properly populated.
+// It handles cases where Name/Arguments might be in different locations (top-level vs Function)
+// and ensures both are populated consistently.
+func NormalizeToolCall(tc ToolCall) ToolCall {
+ normalized := tc
+
+ // Ensure Name is populated from Function if not set
+ if normalized.Name == "" && normalized.Function != nil {
+ normalized.Name = normalized.Function.Name
+ }
+
+ // Ensure Arguments is not nil
+ if normalized.Arguments == nil {
+ normalized.Arguments = map[string]interface{}{}
+ }
+
+ // Parse Arguments from Function.Arguments if not already set
+ if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" {
+ var parsed map[string]interface{}
+ if err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil {
+ normalized.Arguments = parsed
+ }
+ }
+
+ // Ensure Function is populated with consistent values
+ argsJSON, _ := json.Marshal(normalized.Arguments)
+ if normalized.Function == nil {
+ normalized.Function = &FunctionCall{
+ Name: normalized.Name,
+ Arguments: string(argsJSON),
+ }
+ } else {
+ if normalized.Function.Name == "" {
+ normalized.Function.Name = normalized.Name
+ }
+ if normalized.Name == "" {
+ normalized.Name = normalized.Function.Name
+ }
+ if normalized.Function.Arguments == "" {
+ normalized.Function.Arguments = string(argsJSON)
+ }
+ }
+
+ return normalized
+}
diff --git a/pkg/providers/types.go b/pkg/providers/types.go
index 88b62e975..e783e6348 100644
--- a/pkg/providers/types.go
+++ b/pkg/providers/types.go
@@ -1,52 +1,66 @@
package providers
-import "context"
+import (
+ "context"
+ "fmt"
-type ToolCall struct {
- ID string `json:"id"`
- Type string `json:"type,omitempty"`
- Function *FunctionCall `json:"function,omitempty"`
- Name string `json:"name,omitempty"`
- Arguments map[string]interface{} `json:"arguments,omitempty"`
-}
+ "github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
+)
-type FunctionCall struct {
- Name string `json:"name"`
- Arguments string `json:"arguments"`
-}
-
-type LLMResponse struct {
- Content string `json:"content"`
- ToolCalls []ToolCall `json:"tool_calls,omitempty"`
- FinishReason string `json:"finish_reason"`
- Usage *UsageInfo `json:"usage,omitempty"`
-}
-
-type UsageInfo struct {
- PromptTokens int `json:"prompt_tokens"`
- CompletionTokens int `json:"completion_tokens"`
- TotalTokens int `json:"total_tokens"`
-}
-
-type Message struct {
- Role string `json:"role"`
- Content string `json:"content"`
- ToolCalls []ToolCall `json:"tool_calls,omitempty"`
- ToolCallID string `json:"tool_call_id,omitempty"`
-}
+type ToolCall = protocoltypes.ToolCall
+type FunctionCall = protocoltypes.FunctionCall
+type LLMResponse = protocoltypes.LLMResponse
+type UsageInfo = protocoltypes.UsageInfo
+type Message = protocoltypes.Message
+type ToolDefinition = protocoltypes.ToolDefinition
+type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
+type ExtraContent = protocoltypes.ExtraContent
+type GoogleExtra = protocoltypes.GoogleExtra
type LLMProvider interface {
Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error)
GetDefaultModel() string
}
-type ToolDefinition struct {
- Type string `json:"type"`
- Function ToolFunctionDefinition `json:"function"`
+// FailoverReason classifies why an LLM request failed for fallback decisions.
+type FailoverReason string
+
+const (
+ FailoverAuth FailoverReason = "auth"
+ FailoverRateLimit FailoverReason = "rate_limit"
+ FailoverBilling FailoverReason = "billing"
+ FailoverTimeout FailoverReason = "timeout"
+ FailoverFormat FailoverReason = "format"
+ FailoverOverloaded FailoverReason = "overloaded"
+ FailoverUnknown FailoverReason = "unknown"
+)
+
+// FailoverError wraps an LLM provider error with classification metadata.
+type FailoverError struct {
+ Reason FailoverReason
+ Provider string
+ Model string
+ Status int
+ Wrapped error
}
-type ToolFunctionDefinition struct {
- Name string `json:"name"`
- Description string `json:"description"`
- Parameters map[string]interface{} `json:"parameters"`
+func (e *FailoverError) Error() string {
+ return fmt.Sprintf("failover(%s): provider=%s model=%s status=%d: %v",
+ e.Reason, e.Provider, e.Model, e.Status, e.Wrapped)
+}
+
+func (e *FailoverError) Unwrap() error {
+ return e.Wrapped
+}
+
+// IsRetriable returns true if this error should trigger fallback to next candidate.
+// Non-retriable: Format errors (bad request structure, image dimension/size).
+func (e *FailoverError) IsRetriable() bool {
+ return e.Reason != FailoverFormat
+}
+
+// ModelConfig holds primary model and fallback list.
+type ModelConfig struct {
+ Primary string
+ Fallbacks []string
}
diff --git a/pkg/routing/agent_id.go b/pkg/routing/agent_id.go
new file mode 100644
index 000000000..bcf2f0dc0
--- /dev/null
+++ b/pkg/routing/agent_id.go
@@ -0,0 +1,66 @@
+package routing
+
+import (
+ "regexp"
+ "strings"
+)
+
+const (
+ DefaultAgentID = "main"
+ DefaultMainKey = "main"
+ DefaultAccountID = "default"
+ MaxAgentIDLength = 64
+)
+
+var (
+ validIDRe = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,63}$`)
+ invalidCharsRe = regexp.MustCompile(`[^a-z0-9_-]+`)
+ leadingDashRe = regexp.MustCompile(`^-+`)
+ trailingDashRe = regexp.MustCompile(`-+$`)
+)
+
+// NormalizeAgentID sanitizes an agent ID to [a-z0-9][a-z0-9_-]{0,63}.
+// Invalid characters are collapsed to "-". Leading/trailing dashes stripped.
+// Empty input returns DefaultAgentID ("main").
+func NormalizeAgentID(id string) string {
+ trimmed := strings.TrimSpace(id)
+ if trimmed == "" {
+ return DefaultAgentID
+ }
+ lower := strings.ToLower(trimmed)
+ if validIDRe.MatchString(lower) {
+ return lower
+ }
+ result := invalidCharsRe.ReplaceAllString(lower, "-")
+ result = leadingDashRe.ReplaceAllString(result, "")
+ result = trailingDashRe.ReplaceAllString(result, "")
+ if len(result) > MaxAgentIDLength {
+ result = result[:MaxAgentIDLength]
+ }
+ if result == "" {
+ return DefaultAgentID
+ }
+ return result
+}
+
+// NormalizeAccountID sanitizes an account ID. Empty returns DefaultAccountID.
+func NormalizeAccountID(id string) string {
+ trimmed := strings.TrimSpace(id)
+ if trimmed == "" {
+ return DefaultAccountID
+ }
+ lower := strings.ToLower(trimmed)
+ if validIDRe.MatchString(lower) {
+ return lower
+ }
+ result := invalidCharsRe.ReplaceAllString(lower, "-")
+ result = leadingDashRe.ReplaceAllString(result, "")
+ result = trailingDashRe.ReplaceAllString(result, "")
+ if len(result) > MaxAgentIDLength {
+ result = result[:MaxAgentIDLength]
+ }
+ if result == "" {
+ return DefaultAccountID
+ }
+ return result
+}
diff --git a/pkg/routing/agent_id_test.go b/pkg/routing/agent_id_test.go
new file mode 100644
index 000000000..050fe0645
--- /dev/null
+++ b/pkg/routing/agent_id_test.go
@@ -0,0 +1,86 @@
+package routing
+
+import "testing"
+
+func TestNormalizeAgentID_Empty(t *testing.T) {
+ if got := NormalizeAgentID(""); got != DefaultAgentID {
+ t.Errorf("NormalizeAgentID('') = %q, want %q", got, DefaultAgentID)
+ }
+}
+
+func TestNormalizeAgentID_Whitespace(t *testing.T) {
+ if got := NormalizeAgentID(" "); got != DefaultAgentID {
+ t.Errorf("NormalizeAgentID(' ') = %q, want %q", got, DefaultAgentID)
+ }
+}
+
+func TestNormalizeAgentID_Valid(t *testing.T) {
+ tests := []struct {
+ input, want string
+ }{
+ {"main", "main"},
+ {"Main", "main"},
+ {"SALES", "sales"},
+ {"support-bot", "support-bot"},
+ {"agent_1", "agent_1"},
+ {"a", "a"},
+ {"0test", "0test"},
+ }
+ for _, tt := range tests {
+ if got := NormalizeAgentID(tt.input); got != tt.want {
+ t.Errorf("NormalizeAgentID(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ }
+}
+
+func TestNormalizeAgentID_InvalidChars(t *testing.T) {
+ tests := []struct {
+ input, want string
+ }{
+ {"Hello World", "hello-world"},
+ {"agent@123", "agent-123"},
+ {"foo.bar.baz", "foo-bar-baz"},
+ {"--leading", "leading"},
+ {"--both--", "both"},
+ }
+ for _, tt := range tests {
+ if got := NormalizeAgentID(tt.input); got != tt.want {
+ t.Errorf("NormalizeAgentID(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ }
+}
+
+func TestNormalizeAgentID_AllInvalid(t *testing.T) {
+ if got := NormalizeAgentID("@@@"); got != DefaultAgentID {
+ t.Errorf("NormalizeAgentID('@@@') = %q, want %q", got, DefaultAgentID)
+ }
+}
+
+func TestNormalizeAgentID_TruncatesAt64(t *testing.T) {
+ long := ""
+ for i := 0; i < 100; i++ {
+ long += "a"
+ }
+ got := NormalizeAgentID(long)
+ if len(got) > MaxAgentIDLength {
+ t.Errorf("length = %d, want <= %d", len(got), MaxAgentIDLength)
+ }
+}
+
+func TestNormalizeAccountID_Empty(t *testing.T) {
+ if got := NormalizeAccountID(""); got != DefaultAccountID {
+ t.Errorf("NormalizeAccountID('') = %q, want %q", got, DefaultAccountID)
+ }
+}
+
+func TestNormalizeAccountID_Valid(t *testing.T) {
+ if got := NormalizeAccountID("MyBot"); got != "mybot" {
+ t.Errorf("NormalizeAccountID('MyBot') = %q, want 'mybot'", got)
+ }
+}
+
+func TestNormalizeAccountID_InvalidChars(t *testing.T) {
+ if got := NormalizeAccountID("bot@home"); got != "bot-home" {
+ t.Errorf("NormalizeAccountID('bot@home') = %q, want 'bot-home'", got)
+ }
+}
diff --git a/pkg/routing/route.go b/pkg/routing/route.go
new file mode 100644
index 000000000..9eb060c53
--- /dev/null
+++ b/pkg/routing/route.go
@@ -0,0 +1,252 @@
+package routing
+
+import (
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+// RouteInput contains the routing context from an inbound message.
+type RouteInput struct {
+ Channel string
+ AccountID string
+ Peer *RoutePeer
+ ParentPeer *RoutePeer
+ GuildID string
+ TeamID string
+}
+
+// ResolvedRoute is the result of agent routing.
+type ResolvedRoute struct {
+ AgentID string
+ Channel string
+ AccountID string
+ SessionKey string
+ MainSessionKey string
+ MatchedBy string // "binding.peer", "binding.peer.parent", "binding.guild", "binding.team", "binding.account", "binding.channel", "default"
+}
+
+// RouteResolver determines which agent handles a message based on config bindings.
+type RouteResolver struct {
+ cfg *config.Config
+}
+
+// NewRouteResolver creates a new route resolver.
+func NewRouteResolver(cfg *config.Config) *RouteResolver {
+ return &RouteResolver{cfg: cfg}
+}
+
+// ResolveRoute determines which agent handles the message and constructs session keys.
+// Implements the 7-level priority cascade:
+// peer > parent_peer > guild > team > account > channel_wildcard > default
+func (r *RouteResolver) ResolveRoute(input RouteInput) ResolvedRoute {
+ channel := strings.ToLower(strings.TrimSpace(input.Channel))
+ accountID := NormalizeAccountID(input.AccountID)
+ peer := input.Peer
+
+ dmScope := DMScope(r.cfg.Session.DMScope)
+ if dmScope == "" {
+ dmScope = DMScopeMain
+ }
+ identityLinks := r.cfg.Session.IdentityLinks
+
+ bindings := r.filterBindings(channel, accountID)
+
+ choose := func(agentID string, matchedBy string) ResolvedRoute {
+ resolvedAgentID := r.pickAgentID(agentID)
+ sessionKey := strings.ToLower(BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: resolvedAgentID,
+ Channel: channel,
+ AccountID: accountID,
+ Peer: peer,
+ DMScope: dmScope,
+ IdentityLinks: identityLinks,
+ }))
+ mainSessionKey := strings.ToLower(BuildAgentMainSessionKey(resolvedAgentID))
+ return ResolvedRoute{
+ AgentID: resolvedAgentID,
+ Channel: channel,
+ AccountID: accountID,
+ SessionKey: sessionKey,
+ MainSessionKey: mainSessionKey,
+ MatchedBy: matchedBy,
+ }
+ }
+
+ // Priority 1: Peer binding
+ if peer != nil && strings.TrimSpace(peer.ID) != "" {
+ if match := r.findPeerMatch(bindings, peer); match != nil {
+ return choose(match.AgentID, "binding.peer")
+ }
+ }
+
+ // Priority 2: Parent peer binding
+ parentPeer := input.ParentPeer
+ if parentPeer != nil && strings.TrimSpace(parentPeer.ID) != "" {
+ if match := r.findPeerMatch(bindings, parentPeer); match != nil {
+ return choose(match.AgentID, "binding.peer.parent")
+ }
+ }
+
+ // Priority 3: Guild binding
+ guildID := strings.TrimSpace(input.GuildID)
+ if guildID != "" {
+ if match := r.findGuildMatch(bindings, guildID); match != nil {
+ return choose(match.AgentID, "binding.guild")
+ }
+ }
+
+ // Priority 4: Team binding
+ teamID := strings.TrimSpace(input.TeamID)
+ if teamID != "" {
+ if match := r.findTeamMatch(bindings, teamID); match != nil {
+ return choose(match.AgentID, "binding.team")
+ }
+ }
+
+ // Priority 5: Account binding
+ if match := r.findAccountMatch(bindings); match != nil {
+ return choose(match.AgentID, "binding.account")
+ }
+
+ // Priority 6: Channel wildcard binding
+ if match := r.findChannelWildcardMatch(bindings); match != nil {
+ return choose(match.AgentID, "binding.channel")
+ }
+
+ // Priority 7: Default agent
+ return choose(r.resolveDefaultAgentID(), "default")
+}
+
+func (r *RouteResolver) filterBindings(channel, accountID string) []config.AgentBinding {
+ var filtered []config.AgentBinding
+ for _, b := range r.cfg.Bindings {
+ matchChannel := strings.ToLower(strings.TrimSpace(b.Match.Channel))
+ if matchChannel == "" || matchChannel != channel {
+ continue
+ }
+ if !matchesAccountID(b.Match.AccountID, accountID) {
+ continue
+ }
+ filtered = append(filtered, b)
+ }
+ return filtered
+}
+
+func matchesAccountID(matchAccountID, actual string) bool {
+ trimmed := strings.TrimSpace(matchAccountID)
+ if trimmed == "" {
+ return actual == DefaultAccountID
+ }
+ if trimmed == "*" {
+ return true
+ }
+ return strings.ToLower(trimmed) == strings.ToLower(actual)
+}
+
+func (r *RouteResolver) findPeerMatch(bindings []config.AgentBinding, peer *RoutePeer) *config.AgentBinding {
+ for i := range bindings {
+ b := &bindings[i]
+ if b.Match.Peer == nil {
+ continue
+ }
+ peerKind := strings.ToLower(strings.TrimSpace(b.Match.Peer.Kind))
+ peerID := strings.TrimSpace(b.Match.Peer.ID)
+ if peerKind == "" || peerID == "" {
+ continue
+ }
+ if peerKind == strings.ToLower(peer.Kind) && peerID == peer.ID {
+ return b
+ }
+ }
+ return nil
+}
+
+func (r *RouteResolver) findGuildMatch(bindings []config.AgentBinding, guildID string) *config.AgentBinding {
+ for i := range bindings {
+ b := &bindings[i]
+ matchGuild := strings.TrimSpace(b.Match.GuildID)
+ if matchGuild != "" && matchGuild == guildID {
+ return &bindings[i]
+ }
+ }
+ return nil
+}
+
+func (r *RouteResolver) findTeamMatch(bindings []config.AgentBinding, teamID string) *config.AgentBinding {
+ for i := range bindings {
+ b := &bindings[i]
+ matchTeam := strings.TrimSpace(b.Match.TeamID)
+ if matchTeam != "" && matchTeam == teamID {
+ return &bindings[i]
+ }
+ }
+ return nil
+}
+
+func (r *RouteResolver) findAccountMatch(bindings []config.AgentBinding) *config.AgentBinding {
+ for i := range bindings {
+ b := &bindings[i]
+ accountID := strings.TrimSpace(b.Match.AccountID)
+ if accountID == "*" {
+ continue
+ }
+ if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" {
+ continue
+ }
+ return &bindings[i]
+ }
+ return nil
+}
+
+func (r *RouteResolver) findChannelWildcardMatch(bindings []config.AgentBinding) *config.AgentBinding {
+ for i := range bindings {
+ b := &bindings[i]
+ accountID := strings.TrimSpace(b.Match.AccountID)
+ if accountID != "*" {
+ continue
+ }
+ if b.Match.Peer != nil || b.Match.GuildID != "" || b.Match.TeamID != "" {
+ continue
+ }
+ return &bindings[i]
+ }
+ return nil
+}
+
+func (r *RouteResolver) pickAgentID(agentID string) string {
+ trimmed := strings.TrimSpace(agentID)
+ if trimmed == "" {
+ return NormalizeAgentID(r.resolveDefaultAgentID())
+ }
+ normalized := NormalizeAgentID(trimmed)
+ agents := r.cfg.Agents.List
+ if len(agents) == 0 {
+ return normalized
+ }
+ for _, a := range agents {
+ if NormalizeAgentID(a.ID) == normalized {
+ return normalized
+ }
+ }
+ return NormalizeAgentID(r.resolveDefaultAgentID())
+}
+
+func (r *RouteResolver) resolveDefaultAgentID() string {
+ agents := r.cfg.Agents.List
+ if len(agents) == 0 {
+ return DefaultAgentID
+ }
+ for _, a := range agents {
+ if a.Default {
+ id := strings.TrimSpace(a.ID)
+ if id != "" {
+ return NormalizeAgentID(id)
+ }
+ }
+ }
+ if id := strings.TrimSpace(agents[0].ID); id != "" {
+ return NormalizeAgentID(id)
+ }
+ return DefaultAgentID
+}
diff --git a/pkg/routing/route_test.go b/pkg/routing/route_test.go
new file mode 100644
index 000000000..8255db5f9
--- /dev/null
+++ b/pkg/routing/route_test.go
@@ -0,0 +1,297 @@
+package routing
+
+import (
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func testConfig(agents []config.AgentConfig, bindings []config.AgentBinding) *config.Config {
+ return &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: "/tmp/picoclaw-test",
+ Model: "gpt-4",
+ },
+ List: agents,
+ },
+ Bindings: bindings,
+ Session: config.SessionConfig{
+ DMScope: "per-peer",
+ },
+ }
+}
+
+func TestResolveRoute_DefaultAgent_NoBindings(t *testing.T) {
+ cfg := testConfig(nil, nil)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "direct", ID: "user1"},
+ })
+
+ if route.AgentID != DefaultAgentID {
+ t.Errorf("AgentID = %q, want %q", route.AgentID, DefaultAgentID)
+ }
+ if route.MatchedBy != "default" {
+ t.Errorf("MatchedBy = %q, want 'default'", route.MatchedBy)
+ }
+}
+
+func TestResolveRoute_PeerBinding(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "sales", Default: true},
+ {ID: "support"},
+ }
+ bindings := []config.AgentBinding{
+ {
+ AgentID: "support",
+ Match: config.BindingMatch{
+ Channel: "telegram",
+ AccountID: "*",
+ Peer: &config.PeerMatch{Kind: "direct", ID: "user123"},
+ },
+ },
+ }
+ cfg := testConfig(agents, bindings)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "direct", ID: "user123"},
+ })
+
+ if route.AgentID != "support" {
+ t.Errorf("AgentID = %q, want 'support'", route.AgentID)
+ }
+ if route.MatchedBy != "binding.peer" {
+ t.Errorf("MatchedBy = %q, want 'binding.peer'", route.MatchedBy)
+ }
+}
+
+func TestResolveRoute_GuildBinding(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "general", Default: true},
+ {ID: "gaming"},
+ }
+ bindings := []config.AgentBinding{
+ {
+ AgentID: "gaming",
+ Match: config.BindingMatch{
+ Channel: "discord",
+ AccountID: "*",
+ GuildID: "guild-abc",
+ },
+ },
+ }
+ cfg := testConfig(agents, bindings)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "discord",
+ GuildID: "guild-abc",
+ Peer: &RoutePeer{Kind: "channel", ID: "ch1"},
+ })
+
+ if route.AgentID != "gaming" {
+ t.Errorf("AgentID = %q, want 'gaming'", route.AgentID)
+ }
+ if route.MatchedBy != "binding.guild" {
+ t.Errorf("MatchedBy = %q, want 'binding.guild'", route.MatchedBy)
+ }
+}
+
+func TestResolveRoute_TeamBinding(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "general", Default: true},
+ {ID: "work"},
+ }
+ bindings := []config.AgentBinding{
+ {
+ AgentID: "work",
+ Match: config.BindingMatch{
+ Channel: "slack",
+ AccountID: "*",
+ TeamID: "T12345",
+ },
+ },
+ }
+ cfg := testConfig(agents, bindings)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "slack",
+ TeamID: "T12345",
+ Peer: &RoutePeer{Kind: "channel", ID: "C001"},
+ })
+
+ if route.AgentID != "work" {
+ t.Errorf("AgentID = %q, want 'work'", route.AgentID)
+ }
+ if route.MatchedBy != "binding.team" {
+ t.Errorf("MatchedBy = %q, want 'binding.team'", route.MatchedBy)
+ }
+}
+
+func TestResolveRoute_AccountBinding(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "default-agent", Default: true},
+ {ID: "premium"},
+ }
+ bindings := []config.AgentBinding{
+ {
+ AgentID: "premium",
+ Match: config.BindingMatch{
+ Channel: "telegram",
+ AccountID: "bot2",
+ },
+ },
+ }
+ cfg := testConfig(agents, bindings)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "telegram",
+ AccountID: "bot2",
+ Peer: &RoutePeer{Kind: "direct", ID: "user1"},
+ })
+
+ if route.AgentID != "premium" {
+ t.Errorf("AgentID = %q, want 'premium'", route.AgentID)
+ }
+ if route.MatchedBy != "binding.account" {
+ t.Errorf("MatchedBy = %q, want 'binding.account'", route.MatchedBy)
+ }
+}
+
+func TestResolveRoute_ChannelWildcard(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "main", Default: true},
+ {ID: "telegram-bot"},
+ }
+ bindings := []config.AgentBinding{
+ {
+ AgentID: "telegram-bot",
+ Match: config.BindingMatch{
+ Channel: "telegram",
+ AccountID: "*",
+ },
+ },
+ }
+ cfg := testConfig(agents, bindings)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "direct", ID: "user1"},
+ })
+
+ if route.AgentID != "telegram-bot" {
+ t.Errorf("AgentID = %q, want 'telegram-bot'", route.AgentID)
+ }
+ if route.MatchedBy != "binding.channel" {
+ t.Errorf("MatchedBy = %q, want 'binding.channel'", route.MatchedBy)
+ }
+}
+
+func TestResolveRoute_PriorityOrder_PeerBeatsGuild(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "general", Default: true},
+ {ID: "vip"},
+ {ID: "gaming"},
+ }
+ bindings := []config.AgentBinding{
+ {
+ AgentID: "vip",
+ Match: config.BindingMatch{
+ Channel: "discord",
+ AccountID: "*",
+ Peer: &config.PeerMatch{Kind: "direct", ID: "user-vip"},
+ },
+ },
+ {
+ AgentID: "gaming",
+ Match: config.BindingMatch{
+ Channel: "discord",
+ AccountID: "*",
+ GuildID: "guild-1",
+ },
+ },
+ }
+ cfg := testConfig(agents, bindings)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "discord",
+ GuildID: "guild-1",
+ Peer: &RoutePeer{Kind: "direct", ID: "user-vip"},
+ })
+
+ if route.AgentID != "vip" {
+ t.Errorf("AgentID = %q, want 'vip' (peer should beat guild)", route.AgentID)
+ }
+ if route.MatchedBy != "binding.peer" {
+ t.Errorf("MatchedBy = %q, want 'binding.peer'", route.MatchedBy)
+ }
+}
+
+func TestResolveRoute_InvalidAgentFallsToDefault(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "main", Default: true},
+ }
+ bindings := []config.AgentBinding{
+ {
+ AgentID: "nonexistent",
+ Match: config.BindingMatch{
+ Channel: "telegram",
+ AccountID: "*",
+ },
+ },
+ }
+ cfg := testConfig(agents, bindings)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "telegram",
+ })
+
+ if route.AgentID != "main" {
+ t.Errorf("AgentID = %q, want 'main' (invalid agent should fall to default)", route.AgentID)
+ }
+}
+
+func TestResolveRoute_DefaultAgentSelection(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "alpha"},
+ {ID: "beta", Default: true},
+ {ID: "gamma"},
+ }
+ cfg := testConfig(agents, nil)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "cli",
+ })
+
+ if route.AgentID != "beta" {
+ t.Errorf("AgentID = %q, want 'beta' (marked as default)", route.AgentID)
+ }
+}
+
+func TestResolveRoute_NoDefaultUsesFirst(t *testing.T) {
+ agents := []config.AgentConfig{
+ {ID: "alpha"},
+ {ID: "beta"},
+ }
+ cfg := testConfig(agents, nil)
+ r := NewRouteResolver(cfg)
+
+ route := r.ResolveRoute(RouteInput{
+ Channel: "cli",
+ })
+
+ if route.AgentID != "alpha" {
+ t.Errorf("AgentID = %q, want 'alpha' (first in list)", route.AgentID)
+ }
+}
diff --git a/pkg/routing/session_key.go b/pkg/routing/session_key.go
new file mode 100644
index 000000000..e12f0d1d8
--- /dev/null
+++ b/pkg/routing/session_key.go
@@ -0,0 +1,183 @@
+package routing
+
+import (
+ "fmt"
+ "strings"
+)
+
+// DMScope controls DM session isolation granularity.
+type DMScope string
+
+const (
+ DMScopeMain DMScope = "main"
+ DMScopePerPeer DMScope = "per-peer"
+ DMScopePerChannelPeer DMScope = "per-channel-peer"
+ DMScopePerAccountChannelPeer DMScope = "per-account-channel-peer"
+)
+
+// RoutePeer represents a chat peer with kind and ID.
+type RoutePeer struct {
+ Kind string // "direct", "group", "channel"
+ ID string
+}
+
+// SessionKeyParams holds all inputs for session key construction.
+type SessionKeyParams struct {
+ AgentID string
+ Channel string
+ AccountID string
+ Peer *RoutePeer
+ DMScope DMScope
+ IdentityLinks map[string][]string
+}
+
+// ParsedSessionKey is the result of parsing an agent-scoped session key.
+type ParsedSessionKey struct {
+ AgentID string
+ Rest string
+}
+
+// BuildAgentMainSessionKey returns "agent::main".
+func BuildAgentMainSessionKey(agentID string) string {
+ return fmt.Sprintf("agent:%s:%s", NormalizeAgentID(agentID), DefaultMainKey)
+}
+
+// BuildAgentPeerSessionKey constructs a session key based on agent, channel, peer, and DM scope.
+func BuildAgentPeerSessionKey(params SessionKeyParams) string {
+ agentID := NormalizeAgentID(params.AgentID)
+
+ peer := params.Peer
+ if peer == nil {
+ peer = &RoutePeer{Kind: "direct"}
+ }
+ peerKind := strings.TrimSpace(peer.Kind)
+ if peerKind == "" {
+ peerKind = "direct"
+ }
+
+ if peerKind == "direct" {
+ dmScope := params.DMScope
+ if dmScope == "" {
+ dmScope = DMScopeMain
+ }
+ peerID := strings.TrimSpace(peer.ID)
+
+ // Resolve identity links (cross-platform collapse)
+ if dmScope != DMScopeMain && peerID != "" {
+ if linked := resolveLinkedPeerID(params.IdentityLinks, params.Channel, peerID); linked != "" {
+ peerID = linked
+ }
+ }
+ peerID = strings.ToLower(peerID)
+
+ switch dmScope {
+ case DMScopePerAccountChannelPeer:
+ if peerID != "" {
+ channel := normalizeChannel(params.Channel)
+ accountID := NormalizeAccountID(params.AccountID)
+ return fmt.Sprintf("agent:%s:%s:%s:direct:%s", agentID, channel, accountID, peerID)
+ }
+ case DMScopePerChannelPeer:
+ if peerID != "" {
+ channel := normalizeChannel(params.Channel)
+ return fmt.Sprintf("agent:%s:%s:direct:%s", agentID, channel, peerID)
+ }
+ case DMScopePerPeer:
+ if peerID != "" {
+ return fmt.Sprintf("agent:%s:direct:%s", agentID, peerID)
+ }
+ }
+ return BuildAgentMainSessionKey(agentID)
+ }
+
+ // Group/channel peers always get per-peer sessions
+ channel := normalizeChannel(params.Channel)
+ peerID := strings.ToLower(strings.TrimSpace(peer.ID))
+ if peerID == "" {
+ peerID = "unknown"
+ }
+ return fmt.Sprintf("agent:%s:%s:%s:%s", agentID, channel, peerKind, peerID)
+}
+
+// ParseAgentSessionKey extracts agentId and rest from "agent::".
+func ParseAgentSessionKey(sessionKey string) *ParsedSessionKey {
+ raw := strings.TrimSpace(sessionKey)
+ if raw == "" {
+ return nil
+ }
+ parts := strings.SplitN(raw, ":", 3)
+ if len(parts) < 3 {
+ return nil
+ }
+ if parts[0] != "agent" {
+ return nil
+ }
+ agentID := strings.TrimSpace(parts[1])
+ rest := parts[2]
+ if agentID == "" || rest == "" {
+ return nil
+ }
+ return &ParsedSessionKey{AgentID: agentID, Rest: rest}
+}
+
+// IsSubagentSessionKey returns true if the session key represents a subagent.
+func IsSubagentSessionKey(sessionKey string) bool {
+ raw := strings.TrimSpace(sessionKey)
+ if raw == "" {
+ return false
+ }
+ if strings.HasPrefix(strings.ToLower(raw), "subagent:") {
+ return true
+ }
+ parsed := ParseAgentSessionKey(raw)
+ if parsed == nil {
+ return false
+ }
+ return strings.HasPrefix(strings.ToLower(parsed.Rest), "subagent:")
+}
+
+func normalizeChannel(channel string) string {
+ c := strings.TrimSpace(strings.ToLower(channel))
+ if c == "" {
+ return "unknown"
+ }
+ return c
+}
+
+func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID string) string {
+ if len(identityLinks) == 0 {
+ return ""
+ }
+ peerID = strings.TrimSpace(peerID)
+ if peerID == "" {
+ return ""
+ }
+
+ candidates := make(map[string]bool)
+ rawCandidate := strings.ToLower(peerID)
+ if rawCandidate != "" {
+ candidates[rawCandidate] = true
+ }
+ channel = strings.ToLower(strings.TrimSpace(channel))
+ if channel != "" {
+ scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID))
+ candidates[scopedCandidate] = true
+ }
+ if len(candidates) == 0 {
+ return ""
+ }
+
+ for canonical, ids := range identityLinks {
+ canonicalName := strings.TrimSpace(canonical)
+ if canonicalName == "" {
+ continue
+ }
+ for _, id := range ids {
+ normalized := strings.ToLower(strings.TrimSpace(id))
+ if normalized != "" && candidates[normalized] {
+ return canonicalName
+ }
+ }
+ }
+ return ""
+}
diff --git a/pkg/routing/session_key_test.go b/pkg/routing/session_key_test.go
new file mode 100644
index 000000000..81e4ce018
--- /dev/null
+++ b/pkg/routing/session_key_test.go
@@ -0,0 +1,162 @@
+package routing
+
+import "testing"
+
+func TestBuildAgentMainSessionKey(t *testing.T) {
+ got := BuildAgentMainSessionKey("sales")
+ want := "agent:sales:main"
+ if got != want {
+ t.Errorf("BuildAgentMainSessionKey('sales') = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentMainSessionKey_Normalizes(t *testing.T) {
+ got := BuildAgentMainSessionKey("Sales Bot")
+ want := "agent:sales-bot:main"
+ if got != want {
+ t.Errorf("BuildAgentMainSessionKey('Sales Bot') = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentPeerSessionKey_DMScopeMain(t *testing.T) {
+ got := BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: "main",
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "direct", ID: "user123"},
+ DMScope: DMScopeMain,
+ })
+ want := "agent:main:main"
+ if got != want {
+ t.Errorf("DMScopeMain = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentPeerSessionKey_DMScopePerPeer(t *testing.T) {
+ got := BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: "main",
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "direct", ID: "user123"},
+ DMScope: DMScopePerPeer,
+ })
+ want := "agent:main:direct:user123"
+ if got != want {
+ t.Errorf("DMScopePerPeer = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentPeerSessionKey_DMScopePerChannelPeer(t *testing.T) {
+ got := BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: "main",
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "direct", ID: "user123"},
+ DMScope: DMScopePerChannelPeer,
+ })
+ want := "agent:main:telegram:direct:user123"
+ if got != want {
+ t.Errorf("DMScopePerChannelPeer = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentPeerSessionKey_DMScopePerAccountChannelPeer(t *testing.T) {
+ got := BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: "main",
+ Channel: "telegram",
+ AccountID: "bot1",
+ Peer: &RoutePeer{Kind: "direct", ID: "User123"},
+ DMScope: DMScopePerAccountChannelPeer,
+ })
+ want := "agent:main:telegram:bot1:direct:user123"
+ if got != want {
+ t.Errorf("DMScopePerAccountChannelPeer = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentPeerSessionKey_GroupPeer(t *testing.T) {
+ got := BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: "main",
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "group", ID: "chat456"},
+ DMScope: DMScopePerPeer,
+ })
+ want := "agent:main:telegram:group:chat456"
+ if got != want {
+ t.Errorf("GroupPeer = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentPeerSessionKey_NilPeer(t *testing.T) {
+ got := BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: "main",
+ Channel: "telegram",
+ Peer: nil,
+ DMScope: DMScopePerPeer,
+ })
+ // nil peer defaults to direct with empty ID, falls to main
+ want := "agent:main:main"
+ if got != want {
+ t.Errorf("NilPeer = %q, want %q", got, want)
+ }
+}
+
+func TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) {
+ links := map[string][]string{
+ "john": {"telegram:user123", "discord:john#1234"},
+ }
+ got := BuildAgentPeerSessionKey(SessionKeyParams{
+ AgentID: "main",
+ Channel: "telegram",
+ Peer: &RoutePeer{Kind: "direct", ID: "user123"},
+ DMScope: DMScopePerPeer,
+ IdentityLinks: links,
+ })
+ want := "agent:main:direct:john"
+ if got != want {
+ t.Errorf("IdentityLink = %q, want %q", got, want)
+ }
+}
+
+func TestParseAgentSessionKey_Valid(t *testing.T) {
+ parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123")
+ if parsed == nil {
+ t.Fatal("expected non-nil result")
+ }
+ if parsed.AgentID != "sales" {
+ t.Errorf("AgentID = %q, want 'sales'", parsed.AgentID)
+ }
+ if parsed.Rest != "telegram:direct:user123" {
+ t.Errorf("Rest = %q, want 'telegram:direct:user123'", parsed.Rest)
+ }
+}
+
+func TestParseAgentSessionKey_Invalid(t *testing.T) {
+ tests := []string{
+ "",
+ "foo:bar",
+ "notprefix:sales:main",
+ "agent::main",
+ "agent:sales:",
+ }
+ for _, input := range tests {
+ if got := ParseAgentSessionKey(input); got != nil {
+ t.Errorf("ParseAgentSessionKey(%q) = %+v, want nil", input, got)
+ }
+ }
+}
+
+func TestIsSubagentSessionKey(t *testing.T) {
+ tests := []struct {
+ input string
+ want bool
+ }{
+ {"subagent:task-1", true},
+ {"agent:main:subagent:task-1", true},
+ {"agent:main:main", false},
+ {"agent:main:telegram:direct:user123", false},
+ {"", false},
+ }
+ for _, tt := range tests {
+ if got := IsSubagentSessionKey(tt.input); got != tt.want {
+ t.Errorf("IsSubagentSessionKey(%q) = %v, want %v", tt.input, got, tt.want)
+ }
+ }
+}
diff --git a/pkg/session/manager.go b/pkg/session/manager.go
index 9981d4901..12bf33df0 100644
--- a/pkg/session/manager.go
+++ b/pkg/session/manager.go
@@ -264,3 +264,19 @@ func (sm *SessionManager) loadSessions() error {
return nil
}
+
+// SetHistory updates the messages of a session.
+func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+
+ session, ok := sm.sessions[key]
+ if ok {
+ // Create a deep copy to strictly isolate internal state
+ // from the caller's slice.
+ msgs := make([]providers.Message, len(history))
+ copy(msgs, history)
+ session.Messages = msgs
+ session.Updated = time.Now()
+ }
+}
diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go
index a3263c525..0856254e8 100644
--- a/pkg/skills/installer.go
+++ b/pkg/skills/installer.go
@@ -8,7 +8,6 @@ import (
"net/http"
"os"
"path/filepath"
- "strings"
"time"
)
@@ -24,12 +23,6 @@ type AvailableSkill struct {
Tags []string `json:"tags"`
}
-type BuiltinSkill struct {
- Name string `json:"name"`
- Path string `json:"path"`
- Enabled bool `json:"enabled"`
-}
-
func NewSkillInstaller(workspace string) *SkillInstaller {
return &SkillInstaller{
workspace: workspace,
@@ -123,49 +116,3 @@ func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableS
return skills, nil
}
-
-func (si *SkillInstaller) ListBuiltinSkills() []BuiltinSkill {
- builtinSkillsDir := filepath.Join(filepath.Dir(si.workspace), "picoclaw", "skills")
-
- entries, err := os.ReadDir(builtinSkillsDir)
- if err != nil {
- return nil
- }
-
- var skills []BuiltinSkill
- for _, entry := range entries {
- if entry.IsDir() {
- _ = entry
- skillName := entry.Name()
- skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md")
-
- data, err := os.ReadFile(skillFile)
- description := ""
- if err == nil {
- content := string(data)
- if idx := strings.Index(content, "\n"); idx > 0 {
- firstLine := content[:idx]
- if strings.Contains(firstLine, "description:") {
- descLine := strings.Index(content[idx:], "\n")
- if descLine > 0 {
- description = strings.TrimSpace(content[idx+descLine : idx+descLine])
- }
- }
- }
- }
-
- // skill := BuiltinSkill{
- // Name: skillName,
- // Path: description,
- // Enabled: true,
- // }
-
- status := "✓"
- fmt.Printf(" %s %s\n", status, entry.Name())
- if description != "" {
- fmt.Printf(" %s\n", description)
- }
- }
- }
- return skills
-}
diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go
index 1f952c1f5..bb0abbdcc 100644
--- a/pkg/skills/loader.go
+++ b/pkg/skills/loader.go
@@ -2,11 +2,22 @@ package skills
import (
"encoding/json"
+ "errors"
"fmt"
+ "log/slog"
"os"
"path/filepath"
"regexp"
"strings"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
+
+const (
+ MaxNameLength = 64
+ MaxDescriptionLength = 1024
)
type SkillMetadata struct {
@@ -21,6 +32,27 @@ type SkillInfo struct {
Description string `json:"description"`
}
+func (info SkillInfo) validate() error {
+ var errs error
+ if info.Name == "" {
+ errs = errors.Join(errs, errors.New("name is required"))
+ } else {
+ if len(info.Name) > MaxNameLength {
+ errs = errors.Join(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength))
+ }
+ if !namePattern.MatchString(info.Name) {
+ errs = errors.Join(errs, errors.New("name must be alphanumeric with hyphens"))
+ }
+ }
+
+ if info.Description == "" {
+ errs = errors.Join(errs, errors.New("description is required"))
+ } else if len(info.Description) > MaxDescriptionLength {
+ errs = errors.Join(errs, fmt.Errorf("description exceeds %d character", MaxDescriptionLength))
+ }
+ return errs
+}
+
type SkillsLoader struct {
workspace string
workspaceSkills string // workspace skills (项目级别)
@@ -54,6 +86,11 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
metadata := sl.getSkillMetadata(skillFile)
if metadata != nil {
info.Description = metadata.Description
+ info.Name = metadata.Name
+ }
+ if err := info.validate(); err != nil {
+ slog.Warn("invalid skill from workspace", "name", info.Name, "error", err)
+ continue
}
skills = append(skills, info)
}
@@ -89,6 +126,11 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
metadata := sl.getSkillMetadata(skillFile)
if metadata != nil {
info.Description = metadata.Description
+ info.Name = metadata.Name
+ }
+ if err := info.validate(); err != nil {
+ slog.Warn("invalid skill from global", "name", info.Name, "error", err)
+ continue
}
skills = append(skills, info)
}
@@ -123,6 +165,11 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
metadata := sl.getSkillMetadata(skillFile)
if metadata != nil {
info.Description = metadata.Description
+ info.Name = metadata.Name
+ }
+ if err := info.validate(); err != nil {
+ slog.Warn("invalid skill from builtin", "name", info.Name, "error", err)
+ continue
}
skills = append(skills, info)
}
@@ -206,6 +253,11 @@ func (sl *SkillsLoader) BuildSkillsSummary() string {
func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
content, err := os.ReadFile(skillPath)
if err != nil {
+ logger.WarnCF("skills", "Failed to read skill metadata",
+ map[string]interface{}{
+ "skill_path": skillPath,
+ "error": err.Error(),
+ })
return nil
}
@@ -238,10 +290,15 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
// parseSimpleYAML parses simple key: value YAML format
// Example: name: github\n description: "..."
+// Normalizes line endings to handle \n (Unix), \r\n (Windows), and \r (classic Mac)
func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
result := make(map[string]string)
- for _, line := range strings.Split(content, "\n") {
+ // Normalize line endings: convert \r\n and \r to \n
+ normalized := strings.ReplaceAll(content, "\r\n", "\n")
+ normalized = strings.ReplaceAll(normalized, "\r", "\n")
+
+ for _, line := range strings.Split(normalized, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
@@ -261,9 +318,10 @@ func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
}
func (sl *SkillsLoader) extractFrontmatter(content string) string {
- // (?s) enables DOTALL mode so . matches newlines
- // Match first ---, capture everything until next --- on its own line
- re := regexp.MustCompile(`(?s)^---\n(.*)\n---`)
+ // Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
+ // (?s) enables DOTALL so . matches newlines;
+ // ^--- at start, then ... --- at start of line, honoring all three line ending types
+ re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`)
match := re.FindStringSubmatch(content)
if len(match) > 1 {
return match[1]
@@ -272,7 +330,11 @@ func (sl *SkillsLoader) extractFrontmatter(content string) string {
}
func (sl *SkillsLoader) stripFrontmatter(content string) string {
- re := regexp.MustCompile(`^---\n.*?\n---\n`)
+ // Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
+ // (?s) enables DOTALL so . matches newlines;
+ // ^--- at start, then ... --- at start of line, honoring all three line ending types
+ // Match zero or more trailing line endings after closing --- (handles both with and without blank lines)
+ re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
return re.ReplaceAllString(content, "")
}
diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go
new file mode 100644
index 000000000..efadcdbf2
--- /dev/null
+++ b/pkg/skills/loader_test.go
@@ -0,0 +1,179 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSkillsInfoValidate(t *testing.T) {
+ testcases := []struct {
+ name string
+ skillName string
+ description string
+ wantErr bool
+ errContains []string
+ }{
+ {
+ name: "valid-skill",
+ skillName: "valid-skill",
+ description: "a valid skill description",
+ wantErr: false,
+ },
+ {
+ name: "empty-name",
+ skillName: "",
+ description: "description without name",
+ wantErr: true,
+ errContains: []string{"name is required"},
+ },
+ {
+ name: "empty-description",
+ skillName: "skill-without-description",
+ description: "",
+ wantErr: true,
+ errContains: []string{"description is required"},
+ },
+ {
+ name: "empty-both",
+ skillName: "",
+ description: "",
+ wantErr: true,
+ errContains: []string{"name is required", "description is required"},
+ },
+ {
+ name: "name-with-spaces",
+ skillName: "skill with spaces",
+ description: "invalid name with spaces",
+ wantErr: true,
+ errContains: []string{"name must be alphanumeric with hyphens"},
+ },
+ {
+ name: "name-with-underscore",
+ skillName: "skill_underscore",
+ description: "invalid name with underscore",
+ wantErr: true,
+ errContains: []string{"name must be alphanumeric with hyphens"},
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ info := SkillInfo{
+ Name: tc.skillName,
+ Description: tc.description,
+ }
+ err := info.validate()
+ if tc.wantErr {
+ assert.Error(t, err)
+ for _, msg := range tc.errContains {
+ assert.ErrorContains(t, err, msg)
+ }
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestExtractFrontmatter(t *testing.T) {
+ sl := &SkillsLoader{}
+
+ testcases := []struct {
+ name string
+ content string
+ expectedName string
+ expectedDesc string
+ lineEndingType string
+ }{
+ {
+ name: "unix-line-endings",
+ lineEndingType: "Unix (\\n)",
+ content: "---\nname: test-skill\ndescription: A test skill\n---\n\n# Skill Content",
+ expectedName: "test-skill",
+ expectedDesc: "A test skill",
+ },
+ {
+ name: "windows-line-endings",
+ lineEndingType: "Windows (\\r\\n)",
+ content: "---\r\nname: test-skill\r\ndescription: A test skill\r\n---\r\n\r\n# Skill Content",
+ expectedName: "test-skill",
+ expectedDesc: "A test skill",
+ },
+ {
+ name: "classic-mac-line-endings",
+ lineEndingType: "Classic Mac (\\r)",
+ content: "---\rname: test-skill\rdescription: A test skill\r---\r\r# Skill Content",
+ expectedName: "test-skill",
+ expectedDesc: "A test skill",
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ // Extract frontmatter
+ frontmatter := sl.extractFrontmatter(tc.content)
+ assert.NotEmpty(t, frontmatter, "Frontmatter should be extracted for %s line endings", tc.lineEndingType)
+
+ // Parse YAML to get name and description (parseSimpleYAML now handles all line ending types)
+ yamlMeta := sl.parseSimpleYAML(frontmatter)
+ assert.Equal(t, tc.expectedName, yamlMeta["name"], "Name should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType)
+ assert.Equal(t, tc.expectedDesc, yamlMeta["description"], "Description should be correctly parsed from frontmatter with %s line endings", tc.lineEndingType)
+ })
+ }
+}
+
+func TestStripFrontmatter(t *testing.T) {
+ sl := &SkillsLoader{}
+
+ testcases := []struct {
+ name string
+ content string
+ expectedContent string
+ lineEndingType string
+ }{
+ {
+ name: "unix-line-endings",
+ lineEndingType: "Unix (\\n)",
+ content: "---\nname: test-skill\ndescription: A test skill\n---\n\n# Skill Content",
+ expectedContent: "# Skill Content",
+ },
+ {
+ name: "windows-line-endings",
+ lineEndingType: "Windows (\\r\\n)",
+ content: "---\r\nname: test-skill\r\ndescription: A test skill\r\n---\r\n\r\n# Skill Content",
+ expectedContent: "# Skill Content",
+ },
+ {
+ name: "classic-mac-line-endings",
+ lineEndingType: "Classic Mac (\\r)",
+ content: "---\rname: test-skill\rdescription: A test skill\r---\r\r# Skill Content",
+ expectedContent: "# Skill Content",
+ },
+ {
+ name: "unix-line-endings-without-trailing-newline",
+ lineEndingType: "Unix (\\n) without trailing newline",
+ content: "---\nname: test-skill\ndescription: A test skill\n---\n# Skill Content",
+ expectedContent: "# Skill Content",
+ },
+ {
+ name: "windows-line-endings-without-trailing-newline",
+ lineEndingType: "Windows (\\r\\n) without trailing newline",
+ content: "---\r\nname: test-skill\r\ndescription: A test skill\r\n---\r\n# Skill Content",
+ expectedContent: "# Skill Content",
+ },
+ {
+ name: "no-frontmatter",
+ lineEndingType: "No frontmatter",
+ content: "# Skill Content\n\nSome content here.",
+ expectedContent: "# Skill Content\n\nSome content here.",
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ result := sl.stripFrontmatter(tc.content)
+ assert.Equal(t, tc.expectedContent, result, "Frontmatter should be stripped correctly for %s", tc.lineEndingType)
+ })
+ }
+}
diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go
index 0ef745e2b..e2764d8ac 100644
--- a/pkg/tools/cron.go
+++ b/pkg/tools/cron.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/utils"
)
@@ -28,12 +29,15 @@ type CronTool struct {
}
// NewCronTool creates a new CronTool
-func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string) *CronTool {
+// execTimeout: 0 means no timeout, >0 sets the timeout duration
+func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *CronTool {
+ execTool := NewExecToolWithConfig(workspace, restrict, config)
+ execTool.SetTimeout(execTimeout)
return &CronTool{
cronService: cronService,
executor: executor,
msgBus: msgBus,
- execTool: NewExecTool(workspace, false),
+ execTool: execTool,
}
}
diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go
index 237687734..09063ea0a 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -29,13 +29,54 @@ func validatePath(path, workspace string, restrict bool) (string, error) {
}
}
- if restrict && !strings.HasPrefix(absPath, absWorkspace) {
- return "", fmt.Errorf("access denied: path is outside the workspace")
+ if restrict {
+ if !isWithinWorkspace(absPath, absWorkspace) {
+ return "", fmt.Errorf("access denied: path is outside the workspace")
+ }
+
+ workspaceReal := absWorkspace
+ if resolved, err := filepath.EvalSymlinks(absWorkspace); err == nil {
+ workspaceReal = resolved
+ }
+
+ if resolved, err := filepath.EvalSymlinks(absPath); err == nil {
+ if !isWithinWorkspace(resolved, workspaceReal) {
+ return "", fmt.Errorf("access denied: symlink resolves outside workspace")
+ }
+ } else if os.IsNotExist(err) {
+ if parentResolved, err := resolveExistingAncestor(filepath.Dir(absPath)); err == nil {
+ if !isWithinWorkspace(parentResolved, workspaceReal) {
+ return "", fmt.Errorf("access denied: symlink resolves outside workspace")
+ }
+ } else if !os.IsNotExist(err) {
+ return "", fmt.Errorf("failed to resolve path: %w", err)
+ }
+ } else {
+ return "", fmt.Errorf("failed to resolve path: %w", err)
+ }
}
return absPath, nil
}
+func resolveExistingAncestor(path string) (string, error) {
+ for current := filepath.Clean(path); ; current = filepath.Dir(current) {
+ if resolved, err := filepath.EvalSymlinks(current); err == nil {
+ return resolved, nil
+ } else if !os.IsNotExist(err) {
+ return "", err
+ }
+ if filepath.Dir(current) == current {
+ return "", os.ErrNotExist
+ }
+ }
+}
+
+func isWithinWorkspace(candidate, workspace string) bool {
+ rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate))
+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
+}
+
type ReadFileTool struct {
workspace string
restrict bool
diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go
index 2707f29b5..958036419 100644
--- a/pkg/tools/filesystem_test.go
+++ b/pkg/tools/filesystem_test.go
@@ -247,3 +247,35 @@ func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
t.Errorf("Expected success with default path '.', got IsError=true: %s", result.ForLLM)
}
}
+
+// Block paths that look inside workspace but point outside via symlink.
+func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
+
+ root := t.TempDir()
+ workspace := filepath.Join(root, "workspace")
+ if err := os.MkdirAll(workspace, 0755); err != nil {
+ t.Fatalf("failed to create workspace: %v", err)
+ }
+
+ secret := filepath.Join(root, "secret.txt")
+ if err := os.WriteFile(secret, []byte("top secret"), 0644); err != nil {
+ t.Fatalf("failed to write secret file: %v", err)
+ }
+
+ link := filepath.Join(workspace, "leak.txt")
+ if err := os.Symlink(secret, link); err != nil {
+ t.Skipf("symlink not supported in this environment: %v", err)
+ }
+
+ tool := NewReadFileTool(workspace, true)
+ result := tool.Execute(context.Background(), map[string]interface{}{
+ "path": link,
+ })
+
+ if !result.IsError {
+ t.Fatalf("expected symlink escape to be blocked")
+ }
+ if !strings.Contains(result.ForLLM, "symlink resolves outside workspace") {
+ t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
+ }
+}
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index 1ca3fc35a..d9430672f 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -11,6 +11,8 @@ import (
"runtime"
"strings"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/config"
)
type ExecTool struct {
@@ -21,16 +23,82 @@ type ExecTool struct {
restrictToWorkspace bool
}
+var defaultDenyPatterns = []*regexp.Regexp{
+ regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`),
+ regexp.MustCompile(`\bdel\s+/[fq]\b`),
+ regexp.MustCompile(`\brmdir\s+/s\b`),
+ regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args)
+ regexp.MustCompile(`\bdd\s+if=`),
+ regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null)
+ regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`),
+ regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`),
+ regexp.MustCompile(`\$\([^)]+\)`),
+ regexp.MustCompile(`\$\{[^}]+\}`),
+ regexp.MustCompile("`[^`]+`"),
+ regexp.MustCompile(`\|\s*sh\b`),
+ regexp.MustCompile(`\|\s*bash\b`),
+ regexp.MustCompile(`;\s*rm\s+-[rf]`),
+ regexp.MustCompile(`&&\s*rm\s+-[rf]`),
+ regexp.MustCompile(`\|\|\s*rm\s+-[rf]`),
+ regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`),
+ regexp.MustCompile(`<<\s*EOF`),
+ regexp.MustCompile(`\$\(\s*cat\s+`),
+ regexp.MustCompile(`\$\(\s*curl\s+`),
+ regexp.MustCompile(`\$\(\s*wget\s+`),
+ regexp.MustCompile(`\$\(\s*which\s+`),
+ regexp.MustCompile(`\bsudo\b`),
+ regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`),
+ regexp.MustCompile(`\bchown\b`),
+ regexp.MustCompile(`\bpkill\b`),
+ regexp.MustCompile(`\bkillall\b`),
+ regexp.MustCompile(`\bkill\s+-[9]\b`),
+ regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`),
+ regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`),
+ regexp.MustCompile(`\bnpm\s+install\s+-g\b`),
+ regexp.MustCompile(`\bpip\s+install\s+--user\b`),
+ regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`),
+ regexp.MustCompile(`\byum\s+(install|remove)\b`),
+ regexp.MustCompile(`\bdnf\s+(install|remove)\b`),
+ regexp.MustCompile(`\bdocker\s+run\b`),
+ regexp.MustCompile(`\bdocker\s+exec\b`),
+ regexp.MustCompile(`\bgit\s+push\b`),
+ regexp.MustCompile(`\bgit\s+force\b`),
+ regexp.MustCompile(`\bssh\b.*@`),
+ regexp.MustCompile(`\beval\b`),
+ regexp.MustCompile(`\bsource\s+.*\.sh\b`),
+}
+
func NewExecTool(workingDir string, restrict bool) *ExecTool {
- denyPatterns := []*regexp.Regexp{
- regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`),
- regexp.MustCompile(`\bdel\s+/[fq]\b`),
- regexp.MustCompile(`\brmdir\s+/s\b`),
- regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args)
- regexp.MustCompile(`\bdd\s+if=`),
- regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null)
- regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`),
- regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`),
+ return NewExecToolWithConfig(workingDir, restrict, nil)
+}
+
+func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) *ExecTool {
+ denyPatterns := make([]*regexp.Regexp, 0)
+
+ enableDenyPatterns := true
+ if config != nil {
+ execConfig := config.Tools.Exec
+ enableDenyPatterns = execConfig.EnableDenyPatterns
+ if enableDenyPatterns {
+ if len(execConfig.CustomDenyPatterns) > 0 {
+ fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns)
+ for _, pattern := range execConfig.CustomDenyPatterns {
+ re, err := regexp.Compile(pattern)
+ if err != nil {
+ fmt.Printf("Invalid custom deny pattern %q: %v\n", pattern, err)
+ continue
+ }
+ denyPatterns = append(denyPatterns, re)
+ }
+ } else {
+ denyPatterns = append(denyPatterns, defaultDenyPatterns...)
+ }
+ } else {
+ // If deny patterns are disabled, we won't add any patterns, allowing all commands.
+ fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.")
+ }
+ } else {
+ denyPatterns = append(denyPatterns, defaultDenyPatterns...)
}
return &ExecTool{
@@ -89,7 +157,14 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *To
return ErrorResult(guardError)
}
- cmdCtx, cancel := context.WithTimeout(ctx, t.timeout)
+ // timeout == 0 means no timeout
+ var cmdCtx context.Context
+ var cancel context.CancelFunc
+ if t.timeout > 0 {
+ cmdCtx, cancel = context.WithTimeout(ctx, t.timeout)
+ } else {
+ cmdCtx, cancel = context.WithCancel(ctx)
+ }
defer cancel()
var cmd *exec.Cmd
diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go
index 42dd36a33..f01372467 100644
--- a/pkg/tools/spawn.go
+++ b/pkg/tools/spawn.go
@@ -6,10 +6,11 @@ import (
)
type SpawnTool struct {
- manager *SubagentManager
- originChannel string
- originChatID string
- callback AsyncCallback // For async completion notification
+ manager *SubagentManager
+ originChannel string
+ originChatID string
+ allowlistCheck func(targetAgentID string) bool
+ callback AsyncCallback // For async completion notification
}
func NewSpawnTool(manager *SubagentManager) *SpawnTool {
@@ -45,6 +46,10 @@ func (t *SpawnTool) Parameters() map[string]interface{} {
"type": "string",
"description": "Optional short label for the task (for display)",
},
+ "agent_id": map[string]interface{}{
+ "type": "string",
+ "description": "Optional target agent ID to delegate the task to",
+ },
},
"required": []string{"task"},
}
@@ -55,6 +60,10 @@ func (t *SpawnTool) SetContext(channel, chatID string) {
t.originChatID = chatID
}
+func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
+ t.allowlistCheck = check
+}
+
func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
task, ok := args["task"].(string)
if !ok {
@@ -62,13 +71,21 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) *T
}
label, _ := args["label"].(string)
+ agentID, _ := args["agent_id"].(string)
+
+ // Check allowlist if targeting a specific agent
+ if agentID != "" && t.allowlistCheck != nil {
+ if !t.allowlistCheck(agentID) {
+ return ErrorResult(fmt.Sprintf("not allowed to spawn agent '%s'", agentID))
+ }
+ }
if t.manager == nil {
return ErrorResult("Subagent manager not configured")
}
// Pass callback to manager for async completion notification
- result, err := t.manager.Spawn(ctx, task, label, t.originChannel, t.originChatID, t.callback)
+ result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, t.callback)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err))
}
diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go
index efa1d33aa..294ba6ea8 100644
--- a/pkg/tools/subagent.go
+++ b/pkg/tools/subagent.go
@@ -14,6 +14,7 @@ type SubagentTask struct {
ID string
Task string
Label string
+ AgentID string
OriginChannel string
OriginChatID string
Status string
@@ -22,15 +23,19 @@ type SubagentTask struct {
}
type SubagentManager struct {
- tasks map[string]*SubagentTask
- mu sync.RWMutex
- provider providers.LLMProvider
- defaultModel string
- bus *bus.MessageBus
- workspace string
- tools *ToolRegistry
- maxIterations int
- nextID int
+ tasks map[string]*SubagentTask
+ mu sync.RWMutex
+ provider providers.LLMProvider
+ defaultModel string
+ bus *bus.MessageBus
+ workspace string
+ tools *ToolRegistry
+ maxIterations int
+ maxTokens int
+ temperature float64
+ hasMaxTokens bool
+ hasTemperature bool
+ nextID int
}
func NewSubagentManager(provider providers.LLMProvider, defaultModel, workspace string, bus *bus.MessageBus) *SubagentManager {
@@ -46,6 +51,16 @@ func NewSubagentManager(provider providers.LLMProvider, defaultModel, workspace
}
}
+// SetLLMOptions sets max tokens and temperature for subagent LLM calls.
+func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+ sm.maxTokens = maxTokens
+ sm.hasMaxTokens = true
+ sm.temperature = temperature
+ sm.hasTemperature = true
+}
+
// SetTools sets the tool registry for subagent execution.
// If not set, subagent will have access to the provided tools.
func (sm *SubagentManager) SetTools(tools *ToolRegistry) {
@@ -61,7 +76,7 @@ func (sm *SubagentManager) RegisterTool(tool Tool) {
sm.tools.Register(tool)
}
-func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel, originChatID string, callback AsyncCallback) (string, error) {
+func (sm *SubagentManager) Spawn(ctx context.Context, task, label, agentID, originChannel, originChatID string, callback AsyncCallback) (string, error) {
sm.mu.Lock()
defer sm.mu.Unlock()
@@ -72,6 +87,7 @@ func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel
ID: taskID,
Task: task,
Label: label,
+ AgentID: agentID,
OriginChannel: originChannel,
OriginChatID: originChatID,
Status: "running",
@@ -123,17 +139,29 @@ After completing the task, provide a clear summary of what was done.`
sm.mu.RLock()
tools := sm.tools
maxIter := sm.maxIterations
+ maxTokens := sm.maxTokens
+ temperature := sm.temperature
+ hasMaxTokens := sm.hasMaxTokens
+ hasTemperature := sm.hasTemperature
sm.mu.RUnlock()
+ var llmOptions map[string]any
+ if hasMaxTokens || hasTemperature {
+ llmOptions = map[string]any{}
+ if hasMaxTokens {
+ llmOptions["max_tokens"] = maxTokens
+ }
+ if hasTemperature {
+ llmOptions["temperature"] = temperature
+ }
+ }
+
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
Provider: sm.provider,
Model: sm.defaultModel,
Tools: tools,
MaxIterations: maxIter,
- LLMOptions: map[string]any{
- "max_tokens": 4096,
- "temperature": 0.7,
- },
+ LLMOptions: llmOptions,
}, messages, task.OriginChannel, task.OriginChatID)
sm.mu.Lock()
@@ -281,19 +309,30 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{})
sm.mu.RLock()
tools := sm.tools
maxIter := sm.maxIterations
+ maxTokens := sm.maxTokens
+ temperature := sm.temperature
+ hasMaxTokens := sm.hasMaxTokens
+ hasTemperature := sm.hasTemperature
sm.mu.RUnlock()
+ var llmOptions map[string]any
+ if hasMaxTokens || hasTemperature {
+ llmOptions = map[string]any{}
+ if hasMaxTokens {
+ llmOptions["max_tokens"] = maxTokens
+ }
+ if hasTemperature {
+ llmOptions["temperature"] = temperature
+ }
+ }
+
loopResult, err := RunToolLoop(ctx, ToolLoopConfig{
Provider: sm.provider,
Model: sm.defaultModel,
Tools: tools,
MaxIterations: maxIter,
- LLMOptions: map[string]any{
- "max_tokens": 4096,
- "temperature": 0.7,
- },
+ LLMOptions: llmOptions,
}, messages, t.originChannel, t.originChatID)
-
if err != nil {
return ErrorResult(fmt.Sprintf("Subagent execution failed: %v", err)).WithError(err)
}
diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go
index 8a7d22f24..f960a7fda 100644
--- a/pkg/tools/subagent_tool_test.go
+++ b/pkg/tools/subagent_tool_test.go
@@ -10,9 +10,12 @@ import (
)
// MockLLMProvider is a test implementation of LLMProvider
-type MockLLMProvider struct{}
+type MockLLMProvider struct {
+ lastOptions map[string]interface{}
+}
func (m *MockLLMProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) {
+ m.lastOptions = options
// Find the last user message to generate a response
for i := len(messages) - 1; i >= 0; i-- {
if messages[i].Role == "user" {
@@ -36,6 +39,32 @@ func (m *MockLLMProvider) GetContextWindow() int {
return 4096
}
+func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) {
+ provider := &MockLLMProvider{}
+ manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil)
+ manager.SetLLMOptions(2048, 0.6)
+ tool := NewSubagentTool(manager)
+ tool.SetContext("cli", "direct")
+
+ ctx := context.Background()
+ args := map[string]interface{}{"task": "Do something"}
+ result := tool.Execute(ctx, args)
+
+ if result == nil || result.IsError {
+ t.Fatalf("Expected successful result, got: %+v", result)
+ }
+
+ if provider.lastOptions == nil {
+ t.Fatal("Expected LLM options to be passed, got nil")
+ }
+ if provider.lastOptions["max_tokens"] != 2048 {
+ t.Fatalf("max_tokens = %v, want %d", provider.lastOptions["max_tokens"], 2048)
+ }
+ if provider.lastOptions["temperature"] != 0.6 {
+ t.Fatalf("temperature = %v, want %v", provider.lastOptions["temperature"], 0.6)
+ }
+}
+
// TestSubagentTool_Name verifies tool name
func TestSubagentTool_Name(t *testing.T) {
provider := &MockLLMProvider{}
diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go
index 1302079b4..08f14cc92 100644
--- a/pkg/tools/toolloop.go
+++ b/pkg/tools/toolloop.go
@@ -55,12 +55,8 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider
// 2. Set default LLM options
llmOpts := config.LLMOptions
if llmOpts == nil {
- llmOpts = map[string]any{
- "max_tokens": 4096,
- "temperature": 0.7,
- }
+ llmOpts = map[string]any{}
}
-
// 3. Call LLM
response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
if err != nil {
@@ -83,15 +79,20 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider
break
}
- // 5. Log tool calls
- toolNames := make([]string, 0, len(response.ToolCalls))
+ normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))
for _, tc := range response.ToolCalls {
+ normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc))
+ }
+
+ // 5. Log tool calls
+ toolNames := make([]string, 0, len(normalizedToolCalls))
+ for _, tc := range normalizedToolCalls {
toolNames = append(toolNames, tc.Name)
}
logger.InfoCF("toolloop", "LLM requested tool calls",
map[string]any{
"tools": toolNames,
- "count": len(response.ToolCalls),
+ "count": len(normalizedToolCalls),
"iteration": iteration,
})
@@ -100,11 +101,13 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider
Role: "assistant",
Content: response.Content,
}
- for _, tc := range response.ToolCalls {
+ for _, tc := range normalizedToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments)
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
- ID: tc.ID,
- Type: "function",
+ ID: tc.ID,
+ Type: "function",
+ Name: tc.Name,
+ Arguments: tc.Arguments,
Function: &providers.FunctionCall{
Name: tc.Name,
Arguments: string(argumentsJSON),
@@ -114,7 +117,7 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider
messages = append(messages, assistantMsg)
// 7. Execute tool calls
- for _, tc := range response.ToolCalls {
+ for _, tc := range normalizedToolCalls {
argsJSON, _ := json.Marshal(tc.Arguments)
argsPreview := utils.Truncate(string(argsJSON), 200)
logger.InfoCF("toolloop", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
diff --git a/pkg/tools/web.go b/pkg/tools/web.go
index ccd995842..1f5c58ea5 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/web.go
@@ -176,6 +176,71 @@ func stripTags(content string) string {
return re.ReplaceAllString(content, "")
}
+type PerplexitySearchProvider struct {
+ apiKey string
+}
+
+func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
+ searchURL := "https://api.perplexity.ai/chat/completions"
+
+ payload := map[string]interface{}{
+ "model": "sonar",
+ "messages": []map[string]string{
+ {"role": "system", "content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary."},
+ {"role": "user", "content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count)},
+ },
+ "max_tokens": 1000,
+ }
+
+ payloadBytes, err := json.Marshal(payload)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", searchURL, strings.NewReader(string(payloadBytes)))
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+p.apiKey)
+ req.Header.Set("User-Agent", userAgent)
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("Perplexity API error: %s", string(body))
+ }
+
+ var searchResp struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ }
+
+ if err := json.Unmarshal(body, &searchResp); err != nil {
+ return "", fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ if len(searchResp.Choices) == 0 {
+ return fmt.Sprintf("No results for: %s", query), nil
+ }
+
+ return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil
+}
+
type WebSearchTool struct {
provider SearchProvider
maxResults int
@@ -187,14 +252,22 @@ type WebSearchToolOptions struct {
BraveEnabled bool
DuckDuckGoMaxResults int
DuckDuckGoEnabled bool
+ PerplexityAPIKey string
+ PerplexityMaxResults int
+ PerplexityEnabled bool
}
func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
var provider SearchProvider
maxResults := 5
- // Priority: Brave > DuckDuckGo
- if opts.BraveEnabled && opts.BraveAPIKey != "" {
+ // Priority: Perplexity > Brave > DuckDuckGo
+ if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
+ provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey}
+ if opts.PerplexityMaxResults > 0 {
+ maxResults = opts.PerplexityMaxResults
+ }
+ } else if opts.BraveEnabled && opts.BraveAPIKey != "" {
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey}
if opts.BraveMaxResults > 0 {
maxResults = opts.BraveMaxResults
@@ -419,8 +492,10 @@ func (t *WebFetchTool) extractText(htmlContent string) string {
result = strings.TrimSpace(result)
- re = regexp.MustCompile(`\s+`)
- result = re.ReplaceAllLiteralString(result, " ")
+ re = regexp.MustCompile(`[^\S\n]+`)
+ result = re.ReplaceAllString(result, " ")
+ re = regexp.MustCompile(`\n{3,}`)
+ result = re.ReplaceAllString(result, "\n\n")
lines := strings.Split(result, "\n")
var cleanLines []string
diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go
index 988eada16..7e6d62213 100644
--- a/pkg/tools/web_test.go
+++ b/pkg/tools/web_test.go
@@ -173,19 +173,23 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) {
}
}
-// TestWebTool_WebSearch_NoApiKey verifies that nil is returned when no provider is configured
+// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing
func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
- tool := NewWebSearchTool(WebSearchToolOptions{BraveAPIKey: "", BraveMaxResults: 5})
-
- // Should return nil when no provider is enabled
+ tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""})
if tool != nil {
- t.Errorf("Expected nil when no search provider is configured")
+ t.Errorf("Expected nil tool when Brave API key is empty")
+ }
+
+ // Also nil when nothing is enabled
+ tool = NewWebSearchTool(WebSearchToolOptions{})
+ if tool != nil {
+ t.Errorf("Expected nil tool when no provider is enabled")
}
}
// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query
func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
- tool := NewWebSearchTool(WebSearchToolOptions{BraveAPIKey: "test-key", BraveMaxResults: 5, BraveEnabled: true})
+ tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5})
ctx := context.Background()
args := map[string]interface{}{}
@@ -230,6 +234,80 @@ func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) {
}
}
+// TestWebFetchTool_extractText verifies text extraction preserves newlines
+func TestWebFetchTool_extractText(t *testing.T) {
+ tool := &WebFetchTool{}
+
+ tests := []struct {
+ name string
+ input string
+ wantFunc func(t *testing.T, got string)
+ }{
+ {
+ name: "preserves newlines between block elements",
+ input: "Title
\nParagraph 1
\nParagraph 2
",
+ wantFunc: func(t *testing.T, got string) {
+ lines := strings.Split(got, "\n")
+ if len(lines) < 2 {
+ t.Errorf("Expected multiple lines, got %d: %q", len(lines), got)
+ }
+ if !strings.Contains(got, "Title") || !strings.Contains(got, "Paragraph 1") || !strings.Contains(got, "Paragraph 2") {
+ t.Errorf("Missing expected text: %q", got)
+ }
+ },
+ },
+ {
+ name: "removes script and style tags",
+ input: "Keep this
",
+ wantFunc: func(t *testing.T, got string) {
+ if strings.Contains(got, "alert") || strings.Contains(got, "body{}") {
+ t.Errorf("Expected script/style content removed, got: %q", got)
+ }
+ if !strings.Contains(got, "Keep this") {
+ t.Errorf("Expected 'Keep this' to remain, got: %q", got)
+ }
+ },
+ },
+ {
+ name: "collapses excessive blank lines",
+ input: "A
\n\n\n\n\nB
",
+ wantFunc: func(t *testing.T, got string) {
+ if strings.Contains(got, "\n\n\n") {
+ t.Errorf("Expected excessive blank lines collapsed, got: %q", got)
+ }
+ },
+ },
+ {
+ name: "collapses horizontal whitespace",
+ input: "hello world
",
+ wantFunc: func(t *testing.T, got string) {
+ if strings.Contains(got, " ") {
+ t.Errorf("Expected spaces collapsed, got: %q", got)
+ }
+ if !strings.Contains(got, "hello world") {
+ t.Errorf("Expected 'hello world', got: %q", got)
+ }
+ },
+ },
+ {
+ name: "empty input",
+ input: "",
+ wantFunc: func(t *testing.T, got string) {
+ if got != "" {
+ t.Errorf("Expected empty string, got: %q", got)
+ }
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := tool.extractText(tt.input)
+ tt.wantFunc(t, got)
+ })
+ }
+}
+
// TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain
func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
tool := NewWebFetchTool(50000)
diff --git a/pkg/utils/media.go b/pkg/utils/media.go
index 6345da8fc..2b184f2ec 100644
--- a/pkg/utils/media.go
+++ b/pkg/utils/media.go
@@ -73,9 +73,8 @@ func DownloadFile(url, filename string, opts DownloadOptions) string {
}
// Generate unique filename with UUID prefix to prevent conflicts
- ext := filepath.Ext(filename)
safeName := SanitizeFilename(filename)
- localPath := filepath.Join(mediaDir, uuid.New().String()[:8]+"_"+safeName+ext)
+ localPath := filepath.Join(mediaDir, uuid.New().String()[:8]+"_"+safeName)
// Create HTTP request
req, err := http.NewRequest("GET", url, nil)
diff --git a/pkg/utils/message.go b/pkg/utils/message.go
new file mode 100644
index 000000000..1d05950d9
--- /dev/null
+++ b/pkg/utils/message.go
@@ -0,0 +1,179 @@
+package utils
+
+import (
+ "strings"
+)
+
+// SplitMessage splits long messages into chunks, preserving code block integrity.
+// The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks,
+// but may extend to maxLen when needed.
+// Call SplitMessage with the full text content and the maximum allowed length of a single message;
+// it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks.
+func SplitMessage(content string, maxLen int) []string {
+ var messages []string
+
+ // Dynamic buffer: 10% of maxLen, but at least 50 chars if possible
+ codeBlockBuffer := maxLen / 10
+ if codeBlockBuffer < 50 {
+ codeBlockBuffer = 50
+ }
+ if codeBlockBuffer > maxLen/2 {
+ codeBlockBuffer = maxLen / 2
+ }
+
+ for len(content) > 0 {
+ if len(content) <= maxLen {
+ messages = append(messages, content)
+ break
+ }
+
+ // Effective split point: maxLen minus buffer, to leave room for code blocks
+ effectiveLimit := maxLen - codeBlockBuffer
+ if effectiveLimit < maxLen/2 {
+ effectiveLimit = maxLen / 2
+ }
+
+ // Find natural split point within the effective limit
+ msgEnd := findLastNewline(content[:effectiveLimit], 200)
+ if msgEnd <= 0 {
+ msgEnd = findLastSpace(content[:effectiveLimit], 100)
+ }
+ if msgEnd <= 0 {
+ msgEnd = effectiveLimit
+ }
+
+ // Check if this would end with an incomplete code block
+ candidate := content[:msgEnd]
+ unclosedIdx := findLastUnclosedCodeBlock(candidate)
+
+ if unclosedIdx >= 0 {
+ // Message would end with incomplete code block
+ // Try to extend up to maxLen to include the closing ```
+ if len(content) > msgEnd {
+ closingIdx := findNextClosingCodeBlock(content, msgEnd)
+ if closingIdx > 0 && closingIdx <= maxLen {
+ // Extend to include the closing ```
+ msgEnd = closingIdx
+ } else {
+ // Code block is too long to fit in one chunk or missing closing fence.
+ // Try to split inside by injecting closing and reopening fences.
+ headerEnd := strings.Index(content[unclosedIdx:], "\n")
+ if headerEnd == -1 {
+ headerEnd = unclosedIdx + 3
+ } else {
+ headerEnd += unclosedIdx
+ }
+ header := strings.TrimSpace(content[unclosedIdx:headerEnd])
+
+ // If we have a reasonable amount of content after the header, split inside
+ if msgEnd > headerEnd+20 {
+ // Find a better split point closer to maxLen
+ innerLimit := maxLen - 5 // Leave room for "\n```"
+ betterEnd := findLastNewline(content[:innerLimit], 200)
+ if betterEnd > headerEnd {
+ msgEnd = betterEnd
+ } else {
+ msgEnd = innerLimit
+ }
+ messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```")
+ content = strings.TrimSpace(header + "\n" + content[msgEnd:])
+ continue
+ }
+
+ // Otherwise, try to split before the code block starts
+ newEnd := findLastNewline(content[:unclosedIdx], 200)
+ if newEnd <= 0 {
+ newEnd = findLastSpace(content[:unclosedIdx], 100)
+ }
+ if newEnd > 0 {
+ msgEnd = newEnd
+ } else {
+ // If we can't split before, we MUST split inside (last resort)
+ if unclosedIdx > 20 {
+ msgEnd = unclosedIdx
+ } else {
+ msgEnd = maxLen - 5
+ messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```")
+ content = strings.TrimSpace(header + "\n" + content[msgEnd:])
+ continue
+ }
+ }
+ }
+ }
+ }
+
+ if msgEnd <= 0 {
+ msgEnd = effectiveLimit
+ }
+
+ messages = append(messages, content[:msgEnd])
+ content = strings.TrimSpace(content[msgEnd:])
+ }
+
+ return messages
+}
+
+// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ```
+// Returns the position of the opening ``` or -1 if all code blocks are complete
+func findLastUnclosedCodeBlock(text string) int {
+ inCodeBlock := false
+ lastOpenIdx := -1
+
+ for i := 0; i < len(text); i++ {
+ if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
+ // Toggle code block state on each fence
+ if !inCodeBlock {
+ // Entering a code block: record this opening fence
+ lastOpenIdx = i
+ }
+ inCodeBlock = !inCodeBlock
+ i += 2
+ }
+ }
+
+ if inCodeBlock {
+ return lastOpenIdx
+ }
+ return -1
+}
+
+// findNextClosingCodeBlock finds the next closing ``` starting from a position
+// Returns the position after the closing ``` or -1 if not found
+func findNextClosingCodeBlock(text string, startIdx int) int {
+ for i := startIdx; i < len(text); i++ {
+ if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
+ return i + 3
+ }
+ }
+ return -1
+}
+
+// findLastNewline finds the last newline character within the last N characters
+// Returns the position of the newline or -1 if not found
+func findLastNewline(s string, searchWindow int) int {
+ searchStart := len(s) - searchWindow
+ if searchStart < 0 {
+ searchStart = 0
+ }
+ for i := len(s) - 1; i >= searchStart; i-- {
+ if s[i] == '\n' {
+ return i
+ }
+ }
+ return -1
+}
+
+// findLastSpace finds the last space character within the last N characters
+// Returns the position of the space or -1 if not found
+func findLastSpace(s string, searchWindow int) int {
+ searchStart := len(s) - searchWindow
+ if searchStart < 0 {
+ searchStart = 0
+ }
+ for i := len(s) - 1; i >= searchStart; i-- {
+ if s[i] == ' ' || s[i] == '\t' {
+ return i
+ }
+ }
+ return -1
+}
diff --git a/pkg/utils/message_test.go b/pkg/utils/message_test.go
new file mode 100644
index 000000000..338509437
--- /dev/null
+++ b/pkg/utils/message_test.go
@@ -0,0 +1,151 @@
+package utils
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestSplitMessage(t *testing.T) {
+ longText := strings.Repeat("a", 2500)
+ longCode := "```go\n" + strings.Repeat("fmt.Println(\"hello\")\n", 100) + "```" // ~2100 chars
+
+ tests := []struct {
+ name string
+ content string
+ maxLen int
+ expectChunks int // Check number of chunks
+ checkContent func(t *testing.T, chunks []string) // Custom validation
+ }{
+ {
+ name: "Empty message",
+ content: "",
+ maxLen: 2000,
+ expectChunks: 0,
+ },
+ {
+ name: "Short message fits in one chunk",
+ content: "Hello world",
+ maxLen: 2000,
+ expectChunks: 1,
+ },
+ {
+ name: "Simple split regular text",
+ content: longText,
+ maxLen: 2000,
+ expectChunks: 2,
+ checkContent: func(t *testing.T, chunks []string) {
+ if len(chunks[0]) > 2000 {
+ t.Errorf("Chunk 0 too large: %d", len(chunks[0]))
+ }
+ if len(chunks[0])+len(chunks[1]) != len(longText) {
+ t.Errorf("Total length mismatch. Got %d, want %d", len(chunks[0])+len(chunks[1]), len(longText))
+ }
+ },
+ },
+ {
+ name: "Split at newline",
+ // 1750 chars then newline, then more chars.
+ // Dynamic buffer: 2000 / 10 = 200.
+ // Effective limit: 2000 - 200 = 1800.
+ // Split should happen at newline because it's at 1750 (< 1800).
+ // Total length must > 2000 to trigger split. 1750 + 1 + 300 = 2051.
+ content: strings.Repeat("a", 1750) + "\n" + strings.Repeat("b", 300),
+ maxLen: 2000,
+ expectChunks: 2,
+ checkContent: func(t *testing.T, chunks []string) {
+ if len(chunks[0]) != 1750 {
+ t.Errorf("Expected chunk 0 to be 1750 length (split at newline), got %d", len(chunks[0]))
+ }
+ if chunks[1] != strings.Repeat("b", 300) {
+ t.Errorf("Chunk 1 content mismatch. Len: %d", len(chunks[1]))
+ }
+ },
+ },
+ {
+ name: "Long code block split",
+ content: "Prefix\n" + longCode,
+ maxLen: 2000,
+ expectChunks: 2,
+ checkContent: func(t *testing.T, chunks []string) {
+ // Check that first chunk ends with closing fence
+ if !strings.HasSuffix(chunks[0], "\n```") {
+ t.Error("First chunk should end with injected closing fence")
+ }
+ // Check that second chunk starts with execution header
+ if !strings.HasPrefix(chunks[1], "```go") {
+ t.Error("Second chunk should start with injected code block header")
+ }
+ },
+ },
+ {
+ name: "Preserve Unicode characters",
+ content: strings.Repeat("\u4e16", 1000), // 3000 bytes
+ maxLen: 2000,
+ expectChunks: 2,
+ checkContent: func(t *testing.T, chunks []string) {
+ // Just verify we didn't panic and got valid strings.
+ // Go strings are UTF-8, if we split mid-rune it would be bad,
+ // but standard slicing might do that.
+ // Let's assume standard behavior is acceptable or check if it produces invalid rune?
+ if !strings.Contains(chunks[0], "\u4e16") {
+ t.Error("Chunk should contain unicode characters")
+ }
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := SplitMessage(tc.content, tc.maxLen)
+
+ if tc.expectChunks == 0 {
+ if len(got) != 0 {
+ t.Errorf("Expected 0 chunks, got %d", len(got))
+ }
+ return
+ }
+
+ if len(got) != tc.expectChunks {
+ t.Errorf("Expected %d chunks, got %d", tc.expectChunks, len(got))
+ // Log sizes for debugging
+ for i, c := range got {
+ t.Logf("Chunk %d length: %d", i, len(c))
+ }
+ return // Stop further checks if count assumes specific split
+ }
+
+ if tc.checkContent != nil {
+ tc.checkContent(t, got)
+ }
+ })
+ }
+}
+
+func TestSplitMessage_CodeBlockIntegrity(t *testing.T) {
+ // Focused test for the core requirement: splitting inside a code block preserves syntax highlighting
+
+ // 60 chars total approximately
+ content := "```go\npackage main\n\nfunc main() {\n\tprintln(\"Hello\")\n}\n```"
+ maxLen := 40
+
+ chunks := SplitMessage(content, maxLen)
+
+ if len(chunks) != 2 {
+ t.Fatalf("Expected 2 chunks, got %d: %q", len(chunks), chunks)
+ }
+
+ // First chunk must end with "\n```"
+ if !strings.HasSuffix(chunks[0], "\n```") {
+ t.Errorf("First chunk should end with closing fence. Got: %q", chunks[0])
+ }
+
+ // Second chunk must start with the header "```go"
+ if !strings.HasPrefix(chunks[1], "```go") {
+ t.Errorf("Second chunk should start with code block header. Got: %q", chunks[1])
+ }
+
+ // First chunk should contain meaningful content
+ if len(chunks[0]) > 40 {
+ t.Errorf("First chunk exceeded maxLen: length %d", len(chunks[0]))
+ }
+}