Merge origin/main into fix/issues-179-199-security-model
Resolve config/cron merge conflicts while preserving security hardening and follow-up review fixes: shared private-file writer, glm legacy-model normalization tests, and CI vet/race checks.
This commit is contained in:
commit
11f4a51ea2
154 changed files with 24144 additions and 1728 deletions
10
.dockerignore
Normal file
10
.dockerignore
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.git
|
||||
.gitignore
|
||||
build/
|
||||
.picoclaw/
|
||||
config/
|
||||
.env
|
||||
.env.example
|
||||
*.md
|
||||
LICENSE
|
||||
assets/
|
||||
19
.env.example
Normal file
19
.env.example
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# ── LLM Provider ──────────────────────────
|
||||
# Uncomment and set the API key for your provider
|
||||
# OPENROUTER_API_KEY=sk-or-v1-xxx
|
||||
# ZHIPU_API_KEY=xxx
|
||||
# ANTHROPIC_API_KEY=sk-ant-xxx
|
||||
# OPENAI_API_KEY=sk-xxx
|
||||
# GEMINI_API_KEY=xxx
|
||||
|
||||
# ── Chat Channel ──────────────────────────
|
||||
# TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||
# DISCORD_BOT_TOKEN=xxx
|
||||
# LINE_CHANNEL_SECRET=xxx
|
||||
# LINE_CHANNEL_ACCESS_TOKEN=xxx
|
||||
|
||||
# ── Web Search (optional) ────────────────
|
||||
# BRAVE_SEARCH_API_KEY=BSA...
|
||||
|
||||
# ── Timezone ──────────────────────────────
|
||||
TZ=Asia/Tokyo
|
||||
28
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
28
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
|
|
@ -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
|
||||
23
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
23
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
|
|
@ -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
|
||||
26
.github/ISSUE_TEMPLATE/general-task---todo.md
vendored
Normal file
26
.github/ISSUE_TEMPLATE/general-task---todo.md
vendored
Normal file
|
|
@ -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 #
|
||||
43
.github/pull_request_template.md
vendored
Normal file
43
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
## 📝 Description
|
||||
|
||||
<!-- Please briefly describe the changes and purpose of this PR -->
|
||||
|
||||
## 🗣️ 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
|
||||
|
||||
<!-- Please link the related issue(s) (e.g., Fixes #123, Closes #456) -->
|
||||
|
||||
## 📚 Technical Context (Skip for Docs)
|
||||
- **Reference URL:**
|
||||
- **Reasoning:**
|
||||
|
||||
## 🧪 Test Environment
|
||||
- **Hardware:** <!-- e.g. Raspberry Pi 5, Orange Pi, PC-->
|
||||
- **OS:** <!-- e.g. Debian 12, Ubuntu 22.04 -->
|
||||
- **Model/Provider:** <!-- e.g. OpenAI GPT-4o, Kimi k2, DeepSeek-V3 -->
|
||||
- **Channels:** <!-- e.g. Discord, Telegram, Feishu, ... -->
|
||||
|
||||
|
||||
## 📸 Evidence (Optional)
|
||||
<details>
|
||||
<summary>Click to view Logs/Screenshots</summary>
|
||||
|
||||
<!-- Please paste relevant screenshots or logs here -->
|
||||
|
||||
</details>
|
||||
|
||||
## ☑️ 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.
|
||||
25
.github/workflows/build.yml
vendored
Normal file
25
.github/workflows/build.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
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
|
||||
|
||||
- name: fmt
|
||||
run: |
|
||||
make fmt
|
||||
git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
|
||||
|
||||
- name: Build
|
||||
run: make build-all
|
||||
76
.github/workflows/docker-build.yml
vendored
Normal file
76
.github/workflows/docker-build.yml
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
name: 🐳 Build & Push Docker Image
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
env:
|
||||
GHCR_REGISTRY: ghcr.io
|
||||
GHCR_IMAGE_NAME: ${{ github.repository_owner }}/picoclaw
|
||||
DOCKERHUB_REGISTRY: docker.io
|
||||
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: 🏗️ Build Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
# ── Checkout ──────────────────────────────
|
||||
- name: 📥 Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
# ── Docker Buildx ─────────────────────────
|
||||
- name: 🔧 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# ── Login to GHCR ─────────────────────────
|
||||
- name: 🔑 Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.GHCR_REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# ── Login to Docker Hub ────────────────────
|
||||
- name: 🔑 Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
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: 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,linux/riscv64
|
||||
82
.github/workflows/pr.yml
vendored
Normal file
82
.github/workflows/pr.yml
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
name: PR
|
||||
|
||||
on:
|
||||
pull_request: { }
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Linter
|
||||
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
|
||||
|
||||
- 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
|
||||
|
||||
- name: Check formatting
|
||||
run: |
|
||||
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@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
|
||||
test:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: fmt-check
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
|
||||
- name: Run go test
|
||||
run: go test ./...
|
||||
102
.github/workflows/release.yml
vendored
Normal file
102
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
name: Create Tag and Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Release tag (required, e.g. v0.2.0)"
|
||||
required: true
|
||||
type: string
|
||||
prerelease:
|
||||
description: "Mark as pre-release"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
draft:
|
||||
description: "Create as draft"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
create-tag:
|
||||
name: Create Git Tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
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 "$RELEASE_TAG" -m "Release $RELEASE_TAG"
|
||||
git push origin "$RELEASE_TAG"
|
||||
|
||||
release:
|
||||
name: GoReleaser Release
|
||||
needs: create-tag
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout tag
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ inputs.tag }}
|
||||
|
||||
- name: Setup Go from go.mod
|
||||
id: setup-go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
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 }}
|
||||
20
.gitignore
vendored
20
.gitignore
vendored
|
|
@ -1,5 +1,7 @@
|
|||
# Binaries
|
||||
# Go build artifacts
|
||||
bin/
|
||||
build/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
|
|
@ -8,14 +10,23 @@ bin/
|
|||
*.out
|
||||
/picoclaw
|
||||
/picoclaw-test
|
||||
cmd/picoclaw/workspace
|
||||
|
||||
# Picoclaw specific
|
||||
|
||||
# PicoClaw
|
||||
.picoclaw/
|
||||
config.json
|
||||
sessions/
|
||||
build/
|
||||
|
||||
# Coverage
|
||||
|
||||
# Secrets & Config (keep templates, ignore actual secrets)
|
||||
.env
|
||||
config/config.json
|
||||
|
||||
# Test
|
||||
coverage.txt
|
||||
coverage.html
|
||||
|
||||
|
|
@ -24,3 +35,12 @@ coverage.html
|
|||
|
||||
# Ralph workspace
|
||||
ralph/
|
||||
.ralph/
|
||||
tasks/
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
|
|
|
|||
184
.golangci.yaml
Normal file
184
.golangci.yaml
Normal file
|
|
@ -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
|
||||
87
.goreleaser.yaml
Normal file
87
.goreleaser.yaml
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./cmd/picoclaw
|
||||
|
||||
builds:
|
||||
- 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
|
||||
- darwin
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- riscv64
|
||||
- s390x
|
||||
- mips64
|
||||
- arm
|
||||
main: ./cmd/picoclaw
|
||||
ignore:
|
||||
- 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`.
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_
|
||||
{{- title .Os }}_
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else if eq .Arch "386" }}i386
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||
# use zip for windows archives
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
formats: [zip]
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
|
||||
# upx:
|
||||
# - enabled: true
|
||||
# compress: best
|
||||
# lzma: true
|
||||
|
||||
release:
|
||||
footer: >-
|
||||
|
||||
---
|
||||
|
||||
Released by [GoReleaser](https://github.com/goreleaser/goreleaser).
|
||||
43
Dockerfile
Normal file
43
Dockerfile
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# ============================================================
|
||||
# Stage 1: Build the picoclaw binary
|
||||
# ============================================================
|
||||
FROM golang:1.26.0-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git make
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Cache dependencies
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN make build
|
||||
|
||||
# ============================================================
|
||||
# Stage 2: Minimal runtime image
|
||||
# ============================================================
|
||||
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 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"]
|
||||
CMD ["gateway"]
|
||||
10
Dockerfile.goreleaser
Normal file
10
Dockerfile.goreleaser
Normal file
|
|
@ -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"]
|
||||
68
Makefile
68
Makefile
|
|
@ -8,13 +8,14 @@ MAIN_GO=$(CMD_DIR)/main.go
|
|||
|
||||
# Version
|
||||
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.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
|
||||
|
|
@ -38,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
|
||||
|
|
@ -62,22 +65,30 @@ BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
|
|||
# Default target
|
||||
all: build
|
||||
|
||||
## generate: Run generate
|
||||
generate:
|
||||
@echo "Run generate..."
|
||||
@rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
|
||||
@$(GO) generate ./...
|
||||
@echo "Run generate complete"
|
||||
|
||||
## build: Build the picoclaw binary for current platform
|
||||
build:
|
||||
build: generate
|
||||
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||
@$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||
@echo "Build complete: $(BINARY_PATH)"
|
||||
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||
|
||||
## build-all: Build picoclaw for all platforms
|
||||
build-all:
|
||||
build-all: generate
|
||||
@echo "Building for multiple platforms..."
|
||||
@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=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 ./$(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)
|
||||
@echo "All builds complete"
|
||||
|
||||
|
|
@ -88,35 +99,8 @@ install: build
|
|||
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
||||
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
||||
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
|
||||
@echo "Installing builtin skills to $(WORKSPACE_SKILLS_DIR)..."
|
||||
@mkdir -p $(WORKSPACE_SKILLS_DIR)
|
||||
@for skill in $(BUILTIN_SKILLS_DIR)/*/; do \
|
||||
if [ -d "$$skill" ]; then \
|
||||
skill_name=$$(basename "$$skill"); \
|
||||
if [ -f "$$skill/SKILL.md" ]; then \
|
||||
cp -r "$$skill" $(WORKSPACE_SKILLS_DIR); \
|
||||
echo " ✓ Installed skill: $$skill_name"; \
|
||||
fi; \
|
||||
fi; \
|
||||
done
|
||||
@echo "Installation complete!"
|
||||
|
||||
## install-skills: Install builtin skills to workspace
|
||||
install-skills:
|
||||
@echo "Installing builtin skills to $(WORKSPACE_SKILLS_DIR)..."
|
||||
@mkdir -p $(WORKSPACE_SKILLS_DIR)
|
||||
@for skill in $(BUILTIN_SKILLS_DIR)/*/; do \
|
||||
if [ -d "$$skill" ]; then \
|
||||
skill_name=$$(basename "$$skill"); \
|
||||
if [ -f "$$skill/SKILL.md" ]; then \
|
||||
mkdir -p $(WORKSPACE_SKILLS_DIR)/$$skill_name; \
|
||||
cp -r "$$skill" $(WORKSPACE_SKILLS_DIR); \
|
||||
echo " ✓ Installed skill: $$skill_name"; \
|
||||
fi; \
|
||||
fi; \
|
||||
done
|
||||
@echo "Skills installation complete!"
|
||||
|
||||
## uninstall: Remove picoclaw from system
|
||||
uninstall:
|
||||
@echo "Uninstalling $(BINARY_NAME)..."
|
||||
|
|
@ -138,15 +122,31 @@ clean:
|
|||
@rm -rf $(BUILD_DIR)
|
||||
@echo "Clean complete"
|
||||
|
||||
## vet: Run go vet for static analysis
|
||||
vet:
|
||||
@$(GO) vet ./...
|
||||
|
||||
## fmt: Format Go code
|
||||
test:
|
||||
@$(GO) test ./...
|
||||
|
||||
## fmt: Format Go code
|
||||
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)
|
||||
|
|
|
|||
775
README.ja.md
Normal file
775
README.ja.md
Normal file
|
|
@ -0,0 +1,775 @@
|
|||
<div align="center">
|
||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
|
||||
|
||||
<h3>$10 ハードウェア · 10MB RAM · 1秒起動 · 行くぜ、シャコ!</h3>
|
||||
<h3></h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md)
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
---
|
||||
|
||||
🦐 PicoClaw は [nanobot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。Go でゼロからリファクタリングされ、AI エージェント自身がアーキテクチャの移行とコード最適化を推進するセルフブートストラッピングプロセスで構築されました。
|
||||
|
||||
⚡️ $10 のハードウェアで 10MB 未満の RAM で動作:OpenClaw より 99% 少ないメモリ、Mac mini より 98% 安い!
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/licheervnano.png" width="400" height="240">
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 📢 ニュース
|
||||
2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ!
|
||||
|
||||
## ✨ 特徴
|
||||
|
||||
🪶 **超軽量**: メモリフットプリント 10MB 未満 — Clawdbot のコア機能より 99% 小さい。
|
||||
|
||||
💰 **最小コスト**: $10 ハードウェアで動作 — Mac mini より 98% 安い。
|
||||
|
||||
⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。
|
||||
|
||||
🌍 **真のポータビリティ**: RISC-V、ARM、x86 対応の単一バイナリ。ワンクリックで Go!
|
||||
|
||||
🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| --- | --- | --- |--- |
|
||||
| **言語** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB |>100MB| **< 10MB** |
|
||||
| **起動時間**</br>(0.8GHz コア) | >500秒 | >30秒 | **<1秒** |
|
||||
| **コスト** | Mac Mini 599$ | 大半の Linux SBC </br>~50$ |**あらゆる Linux ボード**</br>**最安 10$** |
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
|
||||
## 🦾 デモンストレーション
|
||||
### 🛠️ スタンダードアシスタントワークフロー
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">🧩 フルスタックエンジニア</p></th>
|
||||
<th><p align="center">🗂️ ログ&計画管理</p></th>
|
||||
<th><p align="center">🔎 Web 検索&学習</p></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">開発 · デプロイ · スケール</td>
|
||||
<td align="center">スケジュール · 自動化 · メモリ</td>
|
||||
<td align="center">発見 · インサイト · トレンド</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🐜 革新的な省フットプリントデプロイ
|
||||
PicoClaw はほぼすべての Linux デバイスにデプロイできます!
|
||||
|
||||
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) または W(WiFi6) バージョン、最小ホームアシスタントに
|
||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html) または $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) サーバー自動メンテナンスに
|
||||
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) または $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) スマート監視に
|
||||
|
||||
https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4
|
||||
|
||||
🌟 もっと多くのデプロイ事例が待っています!
|
||||
|
||||
## 📦 インストール
|
||||
|
||||
### コンパイル済みバイナリでインストール
|
||||
|
||||
[リリースページ](https://github.com/sipeed/picoclaw/releases) からお使いのプラットフォーム用のファームウェアをダウンロードしてください。
|
||||
|
||||
### ソースからインストール(最新機能、開発向け推奨)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# ビルド(インストール不要)
|
||||
make build
|
||||
|
||||
# 複数プラットフォーム向けビルド
|
||||
make build-all
|
||||
|
||||
# ビルドとインストール
|
||||
make install
|
||||
```
|
||||
|
||||
## 🐳 Docker Compose
|
||||
|
||||
Docker Compose を使えば、ローカルにインストールせずに PicoClaw を実行できます。
|
||||
|
||||
```bash
|
||||
# 1. リポジトリをクローン
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. API キーを設定
|
||||
cp config/config.example.json config/config.json
|
||||
vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定
|
||||
|
||||
# 3. ビルドと起動
|
||||
docker compose --profile gateway up -d
|
||||
|
||||
# 4. ログ確認
|
||||
docker compose logs -f picoclaw-gateway
|
||||
|
||||
# 5. 停止
|
||||
docker compose --profile gateway down
|
||||
```
|
||||
|
||||
### Agent モード(ワンショット)
|
||||
|
||||
```bash
|
||||
# 質問を投げる
|
||||
docker compose run --rm picoclaw-agent -m "What is 2+2?"
|
||||
|
||||
# インタラクティブモード
|
||||
docker compose run --rm picoclaw-agent
|
||||
```
|
||||
|
||||
### リビルド
|
||||
|
||||
```bash
|
||||
docker compose --profile gateway build --no-cache
|
||||
docker compose --profile gateway up -d
|
||||
```
|
||||
|
||||
### 🚀 クイックスタート(ネイティブ)
|
||||
|
||||
> [!TIP]
|
||||
> `~/.picoclaw/config.json` に API キーを設定してください。
|
||||
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||
> Web 検索は **任意** です - 無料の [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
|
||||
|
||||
**1. 初期化**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
**2. 設定** (`~/.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": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**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)
|
||||
- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
|
||||
|
||||
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
|
||||
|
||||
**3. チャット**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
```
|
||||
|
||||
これだけです!2 分で AI アシスタントが動きます。
|
||||
|
||||
---
|
||||
|
||||
## 💬 チャットアプリ
|
||||
|
||||
Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
|
||||
|
||||
| チャネル | セットアップ |
|
||||
|---------|------------|
|
||||
| **Telegram** | 簡単(トークンのみ) |
|
||||
| **Discord** | 簡単(Bot トークン + Intents) |
|
||||
| **QQ** | 簡単(AppID + AppSecret) |
|
||||
| **DingTalk** | 普通(アプリ認証情報) |
|
||||
| **LINE** | 普通(認証情報 + Webhook URL) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b>(推奨)</summary>
|
||||
|
||||
**1. Bot を作成**
|
||||
|
||||
- Telegram を開き、`@BotFather` を検索
|
||||
- `/newbot` を送信、プロンプトに従う
|
||||
- トークンをコピー
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> ユーザー ID は Telegram の `@userinfobot` から取得できます。
|
||||
|
||||
**3. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
**1. Bot を作成**
|
||||
- https://discord.com/developers/applications にアクセス
|
||||
- アプリケーションを作成 → Bot → Add Bot
|
||||
- Bot トークンをコピー
|
||||
|
||||
**2. Intents を有効化**
|
||||
- Bot の設定画面で **MESSAGE CONTENT INTENT** を有効化
|
||||
- (任意)**SERVER MEMBERS INTENT** も有効化
|
||||
|
||||
**3. ユーザー ID を取得**
|
||||
- Discord 設定 → 詳細設定 → **開発者モード** を有効化
|
||||
- 自分のアバターを右クリック → **ユーザーIDをコピー**
|
||||
|
||||
**4. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**5. Bot を招待**
|
||||
- OAuth2 → URL Generator
|
||||
- Scopes: `bot`
|
||||
- Bot Permissions: `Send Messages`, `Read Message History`
|
||||
- 生成された招待 URL を開き、サーバーに Bot を追加
|
||||
|
||||
**6. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
**1. Bot を作成**
|
||||
|
||||
- [QQ オープンプラットフォーム](https://q.qq.com/#) にアクセス
|
||||
- アプリケーションを作成 → **AppID** と **AppSecret** を取得
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"qq": {
|
||||
"enabled": true,
|
||||
"app_id": "YOUR_APP_ID",
|
||||
"app_secret": "YOUR_APP_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `allow_from` を空にすると全ユーザーを許可、QQ番号を指定してアクセス制限可能。
|
||||
|
||||
**3. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>DingTalk</b></summary>
|
||||
|
||||
**1. Bot を作成**
|
||||
|
||||
- [オープンプラットフォーム](https://open.dingtalk.com/) にアクセス
|
||||
- 内部アプリを作成
|
||||
- Client ID と Client Secret をコピー
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"enabled": true,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `allow_from` を空にすると全ユーザーを許可、ユーザーIDを指定してアクセス制限可能。
|
||||
|
||||
**3. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>LINE</b></summary>
|
||||
|
||||
**1. LINE 公式アカウントを作成**
|
||||
|
||||
- [LINE Developers Console](https://developers.line.biz/) にアクセス
|
||||
- プロバイダーを作成 → Messaging API チャネルを作成
|
||||
- **チャネルシークレット** と **チャネルアクセストークン** をコピー
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```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. Webhook URL を設定**
|
||||
|
||||
LINE の Webhook には HTTPS が必要です。リバースプロキシまたはトンネルを使用してください:
|
||||
|
||||
```bash
|
||||
# ngrok の例
|
||||
ngrok http 18791
|
||||
```
|
||||
|
||||
LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。
|
||||
|
||||
**4. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。
|
||||
|
||||
> **Docker Compose**: `picoclaw-gateway` サービスに `ports: ["18791:18791"]` を追加して Webhook ポートを公開してください。
|
||||
|
||||
</details>
|
||||
|
||||
## ⚙️ 設定
|
||||
|
||||
設定ファイル: `~/.picoclaw/config.json`
|
||||
|
||||
### ワークスペース構成
|
||||
|
||||
PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します:
|
||||
|
||||
```
|
||||
~/.picoclaw/workspace/
|
||||
├── sessions/ # 会話セッションと履歴
|
||||
├── memory/ # 長期メモリ(MEMORY.md)
|
||||
├── state/ # 永続状態(最後のチャネルなど)
|
||||
├── cron/ # スケジュールジョブデータベース
|
||||
├── skills/ # カスタムスキル
|
||||
├── AGENTS.md # エージェントの行動ガイド
|
||||
├── HEARTBEAT.md # 定期タスクプロンプト(30分ごとに確認)
|
||||
├── IDENTITY.md # エージェントのアイデンティティ
|
||||
├── SOUL.md # エージェントのソウル
|
||||
├── TOOLS.md # ツールの説明
|
||||
└── USER.md # ユーザー設定
|
||||
```
|
||||
|
||||
### 🔒 セキュリティサンドボックス
|
||||
|
||||
PicoClaw はデフォルトでサンドボックス環境で実行されます。エージェントは設定されたワークスペース内のファイルにのみアクセスし、コマンドを実行できます。
|
||||
|
||||
#### デフォルト設定
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
|-----------|-----------|------|
|
||||
| `workspace` | `~/.picoclaw/workspace` | エージェントの作業ディレクトリ |
|
||||
| `restrict_to_workspace` | `true` | ファイル/コマンドアクセスをワークスペースに制限 |
|
||||
|
||||
#### 保護対象ツール
|
||||
|
||||
`restrict_to_workspace: true` の場合、以下のツールがサンドボックス化されます:
|
||||
|
||||
| ツール | 機能 | 制限 |
|
||||
|-------|------|------|
|
||||
| `read_file` | ファイル読み込み | ワークスペース内のファイルのみ |
|
||||
| `write_file` | ファイル書き込み | ワークスペース内のファイルのみ |
|
||||
| `list_dir` | ディレクトリ一覧 | ワークスペース内のディレクトリのみ |
|
||||
| `edit_file` | ファイル編集 | ワークスペース内のファイルのみ |
|
||||
| `append_file` | ファイル追記 | ワークスペース内のファイルのみ |
|
||||
| `exec` | コマンド実行 | コマンドパスはワークスペース内である必要あり |
|
||||
|
||||
#### exec ツールの追加保護
|
||||
|
||||
`restrict_to_workspace: false` でも、`exec` ツールは以下の危険なコマンドをブロックします:
|
||||
|
||||
- `rm -rf`, `del /f`, `rmdir /s` — 一括削除
|
||||
- `format`, `mkfs`, `diskpart` — ディスクフォーマット
|
||||
- `dd if=` — ディスクイメージング
|
||||
- `/dev/sd[a-z]` への書き込み — 直接ディスク書き込み
|
||||
- `shutdown`, `reboot`, `poweroff` — システムシャットダウン
|
||||
- フォークボム `:(){ :|:& };:`
|
||||
|
||||
#### エラー例
|
||||
|
||||
```
|
||||
[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)}
|
||||
```
|
||||
|
||||
#### 制限の無効化(セキュリティリスク)
|
||||
|
||||
エージェントにワークスペース外のパスへのアクセスが必要な場合:
|
||||
|
||||
**方法1: 設定ファイル**
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"restrict_to_workspace": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**方法2: 環境変数**
|
||||
```bash
|
||||
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||
```
|
||||
|
||||
> ⚠️ **警告**: この制限を無効にすると、エージェントはシステム上の任意のパスにアクセスできるようになります。制御された環境でのみ慎重に使用してください。
|
||||
|
||||
#### セキュリティ境界の一貫性
|
||||
|
||||
`restrict_to_workspace` 設定は、すべての実行パスで一貫して適用されます:
|
||||
|
||||
| 実行パス | セキュリティ境界 |
|
||||
|---------|-----------------|
|
||||
| メインエージェント | `restrict_to_workspace` ✅ |
|
||||
| サブエージェント / Spawn | 同じ制限を継承 ✅ |
|
||||
| ハートビートタスク | 同じ制限を継承 ✅ |
|
||||
|
||||
すべてのパスで同じワークスペース制限が適用されます — サブエージェントやスケジュールタスクを通じてセキュリティ境界をバイパスする方法はありません。
|
||||
|
||||
### ハートビート(定期タスク)
|
||||
|
||||
PicoClaw は自動的に定期タスクを実行できます。ワークスペースに `HEARTBEAT.md` ファイルを作成します:
|
||||
|
||||
```markdown
|
||||
# 定期タスク
|
||||
|
||||
- 重要なメールをチェック
|
||||
- 今後の予定を確認
|
||||
- 天気予報をチェック
|
||||
```
|
||||
|
||||
エージェントは30分ごと(設定可能)にこのファイルを読み込み、利用可能なツールを使ってタスクを実行します。
|
||||
|
||||
#### spawn で非同期タスク実行
|
||||
|
||||
時間のかかるタスク(Web検索、API呼び出し)には `spawn` ツールを使って**サブエージェント**を作成します:
|
||||
|
||||
```markdown
|
||||
# 定期タスク
|
||||
|
||||
## クイックタスク(直接応答)
|
||||
- 現在時刻を報告
|
||||
|
||||
## 長時間タスク(spawn で非同期)
|
||||
- AIニュースを検索して要約
|
||||
- メールをチェックして重要なメッセージを報告
|
||||
```
|
||||
|
||||
**主な特徴:**
|
||||
|
||||
| 機能 | 説明 |
|
||||
|------|------|
|
||||
| **spawn** | 非同期サブエージェントを作成、ハートビートをブロックしない |
|
||||
| **独立コンテキスト** | サブエージェントは独自のコンテキストを持ち、セッション履歴なし |
|
||||
| **message ツール** | サブエージェントは message ツールで直接ユーザーと通信 |
|
||||
| **非ブロッキング** | spawn 後、ハートビートは次のタスクへ継続 |
|
||||
|
||||
#### サブエージェントの通信方法
|
||||
|
||||
```
|
||||
ハートビート発動
|
||||
↓
|
||||
エージェントが HEARTBEAT.md を読む
|
||||
↓
|
||||
長いタスク: spawn サブエージェント
|
||||
↓ ↓
|
||||
次のタスクへ継続 サブエージェントが独立して動作
|
||||
↓ ↓
|
||||
全タスク完了 message ツールを使用
|
||||
↓ ↓
|
||||
HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||
```
|
||||
|
||||
サブエージェントはツール(message、web_search など)にアクセスでき、メインエージェントを経由せずにユーザーと通信できます。
|
||||
|
||||
**設定:**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
|-----------|-----------|------|
|
||||
| `enabled` | `true` | ハートビートの有効/無効 |
|
||||
| `interval` | `30` | チェック間隔(分)、最小5分 |
|
||||
|
||||
**環境変数:**
|
||||
- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化
|
||||
- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔変更
|
||||
|
||||
### 基本設定
|
||||
|
||||
1. **設定ファイルの作成:**
|
||||
|
||||
```bash
|
||||
cp config.example.json config/config.json
|
||||
```
|
||||
|
||||
2. **設定の編集:**
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-..."
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_DISCORD_BOT_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **実行**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "Hello"
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>完全な設定例</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"apiKey": "sk-or-v1-xxx"
|
||||
},
|
||||
"groq": {
|
||||
"apiKey": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "123456:ABC...",
|
||||
"allow_from": ["123456789"]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "",
|
||||
"allow_from": [""]
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": false
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"appId": "cli_xxx",
|
||||
"appSecret": "xxx",
|
||||
"encryptKey": "",
|
||||
"verificationToken": "",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"apiKey": "BSA..."
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## CLI リファレンス
|
||||
|
||||
| コマンド | 説明 |
|
||||
|---------|------|
|
||||
| `picoclaw onboard` | 設定&ワークスペースの初期化 |
|
||||
| `picoclaw agent -m "..."` | エージェントとチャット |
|
||||
| `picoclaw agent` | インタラクティブチャットモード |
|
||||
| `picoclaw gateway` | ゲートウェイを起動 |
|
||||
| `picoclaw status` | ステータスを表示 |
|
||||
|
||||
## 🤝 コントリビュート&ロードマップ
|
||||
|
||||
PR 歓迎!コードベースは意図的に小さく読みやすくしています。🤗
|
||||
|
||||
Discord: https://discord.gg/V4sAZ9XWpN
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
|
||||
|
||||
## 🐛 トラブルシューティング
|
||||
|
||||
### Web 検索で「API 設定の問題」と表示される
|
||||
|
||||
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
|
||||
|
||||
Web 検索を有効にするには:
|
||||
1. [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
|
||||
2. `~/.picoclaw/config.json` に追加:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### コンテンツフィルタリングエラーが出る
|
||||
|
||||
一部のプロバイダー(Zhipu など)にはコンテンツフィルタリングがあります。クエリを言い換えるか、別のモデルを使用してください。
|
||||
|
||||
### Telegram Bot で「Conflict: terminated by other getUpdates」と表示される
|
||||
|
||||
別のインスタンスが実行中の場合に発生します。`picoclaw gateway` が 1 つだけ実行されていることを確認してください。
|
||||
|
||||
---
|
||||
|
||||
## 📝 API キー比較
|
||||
|
||||
| サービス | 無料枠 | ユースケース |
|
||||
|---------|--------|------------|
|
||||
| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
|
||||
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
||||
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
||||
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
||||
430
README.md
430
README.md
|
|
@ -4,14 +4,17 @@
|
|||
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
|
||||
|
||||
<h3>$10 Hardware · 10MB RAM · 1s Boot · 皮皮虾,我们走!</h3>
|
||||
<h3></h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **English**
|
||||
</div>
|
||||
|
||||
---
|
||||
|
|
@ -35,9 +38,23 @@
|
|||
</tr>
|
||||
</table>
|
||||
|
||||
## 📢 News
|
||||
> [!CAUTION]
|
||||
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
||||
>
|
||||
> * **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.
|
||||
|
||||
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 皮皮虾,我们走!
|
||||
|
||||
## 📢 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.
|
||||
|
||||
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
|
||||
|
||||
## ✨ Features
|
||||
|
||||
|
|
@ -52,7 +69,7 @@
|
|||
🤖 **AI-Bootstrapped**: Autonomous Go-native implementation — 95% Agent-generated core with human-in-the-loop refinement.
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| --- | --- | --- |--- |
|
||||
| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
|
||||
| **Language** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB** |
|
||||
| **Startup**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
||||
|
|
@ -82,6 +99,20 @@
|
|||
</tr>
|
||||
</table>
|
||||
|
||||
### 📱 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!
|
||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
### 🐜 Innovative Low-Footprint Deploy
|
||||
|
||||
PicoClaw can be deployed on almost any Linux device!
|
||||
|
|
@ -118,12 +149,52 @@ make build-all
|
|||
make install
|
||||
```
|
||||
|
||||
## 🐳 Docker Compose
|
||||
|
||||
You can also run PicoClaw using Docker Compose without installing anything locally.
|
||||
|
||||
```bash
|
||||
# 1. Clone this repo
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. Set your API keys
|
||||
cp config/config.example.json config/config.json
|
||||
vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc.
|
||||
|
||||
# 3. Build & Start
|
||||
docker compose --profile gateway up -d
|
||||
|
||||
# 4. Check logs
|
||||
docker compose logs -f picoclaw-gateway
|
||||
|
||||
# 5. Stop
|
||||
docker compose --profile gateway down
|
||||
```
|
||||
|
||||
### Agent Mode (One-shot)
|
||||
|
||||
```bash
|
||||
# Ask a question
|
||||
docker compose run --rm picoclaw-agent -m "What is 2+2?"
|
||||
|
||||
# Interactive mode
|
||||
docker compose run --rm picoclaw-agent
|
||||
```
|
||||
|
||||
### Rebuild
|
||||
|
||||
```bash
|
||||
docker compose --profile gateway build --no-cache
|
||||
docker compose --profile gateway up -d
|
||||
```
|
||||
|
||||
### 🚀 Quick Start
|
||||
|
||||
> [!TIP]
|
||||
> Set your API key in `~/.picoclaw/config.json`.
|
||||
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||
> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month)
|
||||
> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
||||
|
||||
**1. Initialize**
|
||||
|
||||
|
|
@ -152,9 +223,14 @@ picoclaw onboard
|
|||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -163,8 +239,8 @@ picoclaw onboard
|
|||
|
||||
**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)
|
||||
- **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
||||
* **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)
|
||||
* **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
||||
|
||||
> **Note**: See `config.example.json` for a complete configuration template.
|
||||
|
||||
|
|
@ -180,23 +256,24 @@ That's it! You have a working AI assistant in 2 minutes.
|
|||
|
||||
## 💬 Chat Apps
|
||||
|
||||
Talk to your picoclaw through Telegram, Discord, or DingTalk
|
||||
Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
|
||||
|
||||
| Channel | Setup |
|
||||
|---------|-------|
|
||||
| ------------ | ---------------------------------- |
|
||||
| **Telegram** | Easy (just a token) |
|
||||
| **Discord** | Easy (bot token + intents) |
|
||||
| **QQ** | Easy (AppID + AppSecret) |
|
||||
| **DingTalk** | Medium (app credentials) |
|
||||
| **LINE** | Medium (credentials + webhook URL) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b> (Recommended)</summary>
|
||||
|
||||
**1. Create a bot**
|
||||
|
||||
- Open Telegram, search `@BotFather`
|
||||
- Send `/newbot`, follow prompts
|
||||
- Copy the token
|
||||
* Open Telegram, search `@BotFather`
|
||||
* Send `/newbot`, follow prompts
|
||||
* Copy the token
|
||||
|
||||
**2. Configure**
|
||||
|
||||
|
|
@ -206,7 +283,7 @@ Talk to your picoclaw through Telegram, Discord, or DingTalk
|
|||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -227,19 +304,19 @@ picoclaw gateway
|
|||
|
||||
**1. Create a bot**
|
||||
|
||||
- Go to <https://discord.com/developers/applications>
|
||||
- Create an application → Bot → Add Bot
|
||||
- Copy the bot token
|
||||
* Go to <https://discord.com/developers/applications>
|
||||
* Create an application → Bot → Add Bot
|
||||
* Copy the bot token
|
||||
|
||||
**2. Enable intents**
|
||||
|
||||
- In the Bot settings, enable **MESSAGE CONTENT INTENT**
|
||||
- (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
|
||||
* In the Bot settings, enable **MESSAGE CONTENT INTENT**
|
||||
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
|
||||
|
||||
**3. Get your User ID**
|
||||
|
||||
- Discord Settings → Advanced → enable **Developer Mode**
|
||||
- Right-click your avatar → **Copy User ID**
|
||||
* Discord Settings → Advanced → enable **Developer Mode**
|
||||
* Right-click your avatar → **Copy User ID**
|
||||
|
||||
**4. Configure**
|
||||
|
||||
|
|
@ -249,7 +326,7 @@ picoclaw gateway
|
|||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allowFrom": ["YOUR_USER_ID"]
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -257,10 +334,10 @@ picoclaw gateway
|
|||
|
||||
**5. Invite the bot**
|
||||
|
||||
- OAuth2 → URL Generator
|
||||
- Scopes: `bot`
|
||||
- Bot Permissions: `Send Messages`, `Read Message History`
|
||||
- Open the generated invite URL and add the bot to your server
|
||||
* OAuth2 → URL Generator
|
||||
* Scopes: `bot`
|
||||
* Bot Permissions: `Send Messages`, `Read Message History`
|
||||
* Open the generated invite URL and add the bot to your server
|
||||
|
||||
**6. Run**
|
||||
|
||||
|
|
@ -275,7 +352,7 @@ picoclaw gateway
|
|||
|
||||
**1. Create a bot**
|
||||
|
||||
- Go to [QQ Open Platform](https://connect.qq.com/)
|
||||
- Go to [QQ Open Platform](https://q.qq.com/#)
|
||||
- Create an application → Get **AppID** and **AppSecret**
|
||||
|
||||
**2. Configure**
|
||||
|
|
@ -308,9 +385,9 @@ picoclaw gateway
|
|||
|
||||
**1. Create a bot**
|
||||
|
||||
- Go to [Open Platform](https://open.dingtalk.com/)
|
||||
- Create an internal app
|
||||
- Copy Client ID and Client Secret
|
||||
* Go to [Open Platform](https://open.dingtalk.com/)
|
||||
* Create an internal app
|
||||
* Copy Client ID and Client Secret
|
||||
|
||||
**2. Configure**
|
||||
|
||||
|
|
@ -337,6 +414,62 @@ picoclaw gateway
|
|||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>LINE</b></summary>
|
||||
|
||||
**1. Create a LINE Official Account**
|
||||
|
||||
- Go to [LINE Developers Console](https://developers.line.biz/)
|
||||
- Create a provider → Create a Messaging API channel
|
||||
- Copy **Channel Secret** and **Channel Access Token**
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```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. Set up Webhook URL**
|
||||
|
||||
LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
|
||||
|
||||
```bash
|
||||
# Example with ngrok
|
||||
ngrok http 18791
|
||||
```
|
||||
|
||||
Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
|
||||
|
||||
**4. Run**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> In group chats, the bot responds only when @mentioned. Replies quote the original message.
|
||||
|
||||
> **Docker Compose**: Add `ports: ["18791:18791"]` to the `picoclaw-gateway` service to expose the webhook port.
|
||||
|
||||
</details>
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
|
||||
|
||||
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
|
||||
|
||||
**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Config file: `~/.picoclaw/config.json`
|
||||
|
|
@ -349,22 +482,195 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
|
|||
~/.picoclaw/workspace/
|
||||
├── sessions/ # Conversation sessions and history
|
||||
├── memory/ # Long-term memory (MEMORY.md)
|
||||
├── state/ # Persistent state (last channel, etc.)
|
||||
├── cron/ # Scheduled jobs database
|
||||
├── skills/ # Custom skills
|
||||
├── AGENTS.md # Agent behavior guide
|
||||
├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
|
||||
├── IDENTITY.md # Agent identity
|
||||
├── SOUL.md # Agent soul
|
||||
├── TOOLS.md # Tool descriptions
|
||||
└── USER.md # User preferences
|
||||
```
|
||||
|
||||
### 🔒 Security Sandbox
|
||||
|
||||
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
||||
|
||||
#### Default Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
|
||||
| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
|
||||
|
||||
#### Protected Tools
|
||||
|
||||
When `restrict_to_workspace: true`, the following tools are sandboxed:
|
||||
|
||||
| Tool | Function | Restriction |
|
||||
|------|----------|-------------|
|
||||
| `read_file` | Read files | Only files within workspace |
|
||||
| `write_file` | Write files | Only files within workspace |
|
||||
| `list_dir` | List directories | Only directories within workspace |
|
||||
| `edit_file` | Edit files | Only files within workspace |
|
||||
| `append_file` | Append to files | Only files within workspace |
|
||||
| `exec` | Execute commands | Command paths must be within workspace |
|
||||
|
||||
#### Additional Exec Protection
|
||||
|
||||
Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands:
|
||||
|
||||
* `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion
|
||||
* `format`, `mkfs`, `diskpart` — Disk formatting
|
||||
* `dd if=` — Disk imaging
|
||||
* Writing to `/dev/sd[a-z]` — Direct disk writes
|
||||
* `shutdown`, `reboot`, `poweroff` — System shutdown
|
||||
* Fork bomb `:(){ :|:& };:`
|
||||
|
||||
#### Error Examples
|
||||
|
||||
```
|
||||
[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)}
|
||||
```
|
||||
|
||||
#### Disabling Restrictions (Security Risk)
|
||||
|
||||
If you need the agent to access paths outside the workspace:
|
||||
|
||||
**Method 1: Config file**
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"restrict_to_workspace": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Method 2: Environment variable**
|
||||
|
||||
```bash
|
||||
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||
```
|
||||
|
||||
> ⚠️ **Warning**: Disabling this restriction allows the agent to access any path on your system. Use with caution in controlled environments only.
|
||||
|
||||
#### Security Boundary Consistency
|
||||
|
||||
The `restrict_to_workspace` setting applies consistently across all execution paths:
|
||||
|
||||
| Execution Path | Security Boundary |
|
||||
|----------------|-------------------|
|
||||
| Main Agent | `restrict_to_workspace` ✅ |
|
||||
| Subagent / Spawn | Inherits same restriction ✅ |
|
||||
| Heartbeat tasks | Inherits same restriction ✅ |
|
||||
|
||||
All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks.
|
||||
|
||||
### Heartbeat (Periodic Tasks)
|
||||
|
||||
PicoClaw can perform periodic tasks automatically. Create a `HEARTBEAT.md` file in your workspace:
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
|
||||
- Check my email for important messages
|
||||
- Review my calendar for upcoming events
|
||||
- Check the weather forecast
|
||||
```
|
||||
|
||||
The agent will read this file every 30 minutes (configurable) and execute any tasks using available tools.
|
||||
|
||||
#### Async Tasks with Spawn
|
||||
|
||||
For long-running tasks (web search, API calls), use the `spawn` tool to create a **subagent**:
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
|
||||
## Quick Tasks (respond directly)
|
||||
- Report current time
|
||||
|
||||
## Long Tasks (use spawn for async)
|
||||
- Search the web for AI news and summarize
|
||||
- Check email and report important messages
|
||||
```
|
||||
|
||||
**Key behaviors:**
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **spawn** | Creates async subagent, doesn't block heartbeat |
|
||||
| **Independent context** | Subagent has its own context, no session history |
|
||||
| **message tool** | Subagent communicates with user directly via message tool |
|
||||
| **Non-blocking** | After spawning, heartbeat continues to next task |
|
||||
|
||||
#### How Subagent Communication Works
|
||||
|
||||
```
|
||||
Heartbeat triggers
|
||||
↓
|
||||
Agent reads HEARTBEAT.md
|
||||
↓
|
||||
For long task: spawn subagent
|
||||
↓ ↓
|
||||
Continue to next task Subagent works independently
|
||||
↓ ↓
|
||||
All tasks done Subagent uses "message" tool
|
||||
↓ ↓
|
||||
Respond HEARTBEAT_OK User receives result directly
|
||||
```
|
||||
|
||||
The subagent has access to tools (message, web_search, etc.) and can communicate with the user independently without going through the main agent.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `enabled` | `true` | Enable/disable heartbeat |
|
||||
| `interval` | `30` | Check interval in minutes (min: 5) |
|
||||
|
||||
**Environment variables:**
|
||||
|
||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable
|
||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval
|
||||
|
||||
### Providers
|
||||
|
||||
> [!NOTE]
|
||||
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
||||
|
||||
| Provider | Purpose | Get API Key |
|
||||
|----------|---------|-------------|
|
||||
| -------------------------- | --------------------------------------- | ------------------------------------------------------ |
|
||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
|
||||
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||
|
|
@ -373,12 +679,22 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
|
|||
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
|
||||
### 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`).
|
||||
|
||||
<details>
|
||||
<summary><b>Zhipu</b></summary>
|
||||
|
||||
**1. Get API key and base URL**
|
||||
|
||||
- Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||
* Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||
|
||||
**2. Configure**
|
||||
|
||||
|
|
@ -397,8 +713,8 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
|
|||
"zhipu": {
|
||||
"api_key": "Your API Key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -459,10 +775,23 @@ picoclaw agent -m "Hello"
|
|||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "BSA..."
|
||||
"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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -472,7 +801,7 @@ picoclaw agent -m "Hello"
|
|||
## CLI Reference
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| ------------------------- | ----------------------------- |
|
||||
| `picoclaw onboard` | Initialize config & workspace |
|
||||
| `picoclaw agent -m "..."` | Chat with the agent |
|
||||
| `picoclaw agent` | Interactive chat mode |
|
||||
|
|
@ -485,9 +814,9 @@ picoclaw agent -m "Hello"
|
|||
|
||||
PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:
|
||||
|
||||
- **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min
|
||||
- **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours
|
||||
- **Cron expressions**: "Remind me at 9am daily" → uses cron expression
|
||||
* **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min
|
||||
* **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours
|
||||
* **Cron expressions**: "Remind me at 9am daily" → uses cron expression
|
||||
|
||||
Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
|
||||
|
||||
|
|
@ -495,6 +824,12 @@ Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
|
|||
|
||||
PRs welcome! The codebase is intentionally small and readable. 🤗
|
||||
|
||||
Roadmap coming soon...
|
||||
|
||||
Developer group building, Entry Requirement: At least 1 Merged PR.
|
||||
|
||||
User Groups:
|
||||
|
||||
discord: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
|
|
@ -507,16 +842,23 @@ This is normal if you haven't configured a search API key yet. PicoClaw will pro
|
|||
|
||||
To enable web search:
|
||||
|
||||
1. Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month)
|
||||
2. Add to `~/.picoclaw/config.json`:
|
||||
1. **Option 1 (Recommended)**: Get a free API key at [https://brave.com/search/api](https://brave.com/search/api) (2000 free queries/month) for the best results.
|
||||
2. **Option 2 (No Credit Card)**: If you don't have a key, we automatically fall back to **DuckDuckGo** (no key required).
|
||||
|
||||
Add the key to `~/.picoclaw/config.json` if using Brave:
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -536,7 +878,7 @@ This happens when another instance of the bot is running. Make sure only one `pi
|
|||
## 📝 API Key Comparison
|
||||
|
||||
| Service | Free Tier | Use Case |
|
||||
|---------|-----------|-----------|
|
||||
| ---------------- | ------------------- | ------------------------------------- |
|
||||
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
|
||||
| **Zhipu** | 200K tokens/month | Best for Chinese users |
|
||||
| **Brave Search** | 2000 queries/month | Web search functionality |
|
||||
|
|
|
|||
882
README.pt-br.md
Normal file
882
README.pt-br.md
Normal file
|
|
@ -0,0 +1,882 @@
|
|||
<div align="center">
|
||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
|
||||
|
||||
<h3>Hardware de $10 · 10MB de RAM · Boot em 1s · 皮皮虾,我们走!</h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
</p>
|
||||
|
||||
[中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) | **Português**
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
🦐 **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!
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/licheervnano.png" width="400" height="240">
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> [!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**</br>(CPU 0.8GHz) | >500s | >30s | **<1s** |
|
||||
| **Custo** | Mac Mini $599 | Maioria dos SBC Linux </br>~$50 | **Qualquer placa Linux**</br>**A partir de $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
## 🦾 Demonstração
|
||||
|
||||
### 🛠️ Fluxos de Trabalho Padrão do Assistente
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">🧩 Engenharia Full-Stack</p></th>
|
||||
<th><p align="center">🗂️ Gerenciamento de Logs & Planejamento</p></th>
|
||||
<th><p align="center">🔎 Busca Web & Aprendizado</p></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Desenvolver • Implantar • Escalar</td>
|
||||
<td align="center">Agendar • Automatizar • Memorizar</td>
|
||||
<td align="center">Descobrir • Analisar • Tendências</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 📱 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!
|
||||
|
||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
### 🐜 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) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b> (Recomendado)</summary>
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
**1. Criar o bot**
|
||||
|
||||
* Acesse <https://discord.com/developers/applications>
|
||||
* 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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>DingTalk</b></summary>
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>LINE</b></summary>
|
||||
|
||||
**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.
|
||||
|
||||
</details>
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 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) |
|
||||
|
||||
<details>
|
||||
<summary><b>Configuração Zhipu</b></summary>
|
||||
|
||||
**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?"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Exemplo de configuraçao completa</b></summary>
|
||||
|
||||
```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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 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: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
|
||||
## 🐛 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) |
|
||||
859
README.vi.md
Normal file
859
README.vi.md
Normal file
|
|
@ -0,0 +1,859 @@
|
|||
<div align="center">
|
||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
|
||||
|
||||
<h3>Phần cứng $10 · RAM 10MB · Khởi động 1 giây · 皮皮虾,我们走!</h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
</p>
|
||||
|
||||
**Tiếng Việt** | [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
🦐 **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!
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/licheervnano.png" width="400" height="240">
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> [!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**</br>(CPU 0.8GHz) | >500s | >30s | **<1s** |
|
||||
| **Chi phí** | Mac Mini $599 | Hầu hết SBC Linux ~$50 | **Mọi bo mạch Linux**</br>**Chỉ từ $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
## 🦾 Demo
|
||||
|
||||
### 🛠️ Quy trình trợ lý tiêu chuẩn
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">🧩 Lập trình Full-Stack</p></th>
|
||||
<th><p align="center">🗂️ Quản lý Nhật ký & Kế hoạch</p></th>
|
||||
<th><p align="center">🔎 Tìm kiếm Web & Học hỏi</p></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Phát triển • Triển khai • Mở rộng</td>
|
||||
<td align="center">Lên lịch • Tự động hóa • Ghi nhớ</td>
|
||||
<td align="center">Khám phá • Phân tích • Xu hướng</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 🐜 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) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b> (Khuyên dùng)</summary>
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
**1. Tạo bot**
|
||||
|
||||
* Truy cập <https://discord.com/developers/applications>
|
||||
* 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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>DingTalk</b></summary>
|
||||
|
||||
**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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>LINE</b></summary>
|
||||
|
||||
**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.
|
||||
|
||||
</details>
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 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) |
|
||||
|
||||
<details>
|
||||
<summary><b>Cấu hình Zhipu</b></summary>
|
||||
|
||||
**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"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Ví dụ cấu hình đầy đủ</b></summary>
|
||||
|
||||
```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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 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: <https://discord.gg/V4sAZ9XWpN>
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
|
||||
## 🐛 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) |
|
||||
744
README.zh.md
Normal file
744
README.zh.md
Normal file
|
|
@ -0,0 +1,744 @@
|
|||
<div align="center">
|
||||
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
|
||||
|
||||
<h3>10$硬件 · 10MB内存 · 1秒启动 · 皮皮虾,我们走!</h3>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
|
||||
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20RISC--V-blue" alt="Hardware">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||
<br>
|
||||
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
|
||||
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||
</p>
|
||||
|
||||
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md)
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
🦐 **PicoClaw** 是一个受 [nanobot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个“自举”过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。
|
||||
|
||||
⚡️ **极致轻量**:可在 **10 美元** 的硬件上运行,内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存,比 Mac mini 便宜 98%!
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/picoclaw_mem.gif" width="360" height="240">
|
||||
</p>
|
||||
</td>
|
||||
<td align="center" valign="top">
|
||||
<p align="center">
|
||||
<img src="assets/licheervnano.png" width="400" height="240">
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
注意:人手有限,中文文档可能略有滞后,请优先查看英文文档。
|
||||
|
||||
> [!CAUTION]
|
||||
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
||||
> * **无加密货币 (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)。我们将在接下来的周会上进行审查和优先级排序。
|
||||
|
||||
2026-02-09 🎉 **PicoClaw 正式发布!** 仅用 1 天构建,旨在将 AI Agent 带入 10 美元硬件与 <10MB 内存的世界。🦐 PicoClaw(皮皮虾),我们走!
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
🪶 **超轻量级**: 核心功能内存占用 <10MB — 比 Clawdbot 小 99%。
|
||||
|
||||
💰 **极低成本**: 高效到足以在 10 美元的硬件上运行 — 比 Mac mini 便宜 98%。
|
||||
|
||||
⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
|
||||
|
||||
🌍 **真正可移植**: 跨 RISC-V、ARM 和 x86 架构的单二进制文件,一键运行!
|
||||
|
||||
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
|
||||
|
||||
| | OpenClaw | NanoBot | **PicoClaw** |
|
||||
| --- | --- | --- | --- |
|
||||
| **语言** | TypeScript | Python | **Go** |
|
||||
| **RAM** | >1GB | >100MB | **< 10MB** |
|
||||
| **启动时间**</br>(0.8GHz core) | >500s | >30s | **<1s** |
|
||||
| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**</br>**低至 $10** |
|
||||
|
||||
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
## 🦾 演示
|
||||
|
||||
### 🛠️ 标准助手工作流
|
||||
|
||||
<table align="center">
|
||||
<tr align="center">
|
||||
<th><p align="center">🧩 全栈工程师模式</p></th>
|
||||
<th><p align="center">🗂️ 日志与规划管理</p></th>
|
||||
<th><p align="center">🔎 网络搜索与学习</p></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
|
||||
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">开发 • 部署 • 扩展</td>
|
||||
<td align="center">日程 • 自动化 • 记忆</td>
|
||||
<td align="center">发现 • 洞察 • 趋势</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 📱 在手机上轻松运行
|
||||
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即可使用!
|
||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||
|
||||
|
||||
|
||||
|
||||
### 🐜 创新的低占用部署
|
||||
|
||||
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
||||
|
||||
* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
|
||||
* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
|
||||
* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
|
||||
|
||||
[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4)
|
||||
|
||||
🌟 更多部署案例敬请期待!
|
||||
|
||||
## 📦 安装
|
||||
|
||||
### 使用预编译二进制文件安装
|
||||
|
||||
从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的固件。
|
||||
|
||||
### 从源码安装(获取最新特性,开发推荐)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
|
||||
cd picoclaw
|
||||
make deps
|
||||
|
||||
# 构建(无需安装)
|
||||
make build
|
||||
|
||||
# 为多平台构建
|
||||
make build-all
|
||||
|
||||
# 构建并安装
|
||||
make install
|
||||
|
||||
```
|
||||
|
||||
## 🐳 Docker Compose
|
||||
|
||||
您也可以使用 Docker Compose 运行 PicoClaw,无需在本地安装任何环境。
|
||||
|
||||
```bash
|
||||
# 1. 克隆仓库
|
||||
git clone https://github.com/sipeed/picoclaw.git
|
||||
cd picoclaw
|
||||
|
||||
# 2. 设置 API Key
|
||||
cp config/config.example.json config/config.json
|
||||
vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
|
||||
|
||||
# 3. 构建并启动
|
||||
docker compose --profile gateway up -d
|
||||
|
||||
# 4. 查看日志
|
||||
docker compose logs -f picoclaw-gateway
|
||||
|
||||
# 5. 停止
|
||||
docker compose --profile gateway down
|
||||
|
||||
```
|
||||
|
||||
### Agent 模式 (一次性运行)
|
||||
|
||||
```bash
|
||||
# 提问
|
||||
docker compose run --rm picoclaw-agent -m "2+2 等于几?"
|
||||
|
||||
# 交互模式
|
||||
docker compose run --rm picoclaw-agent
|
||||
|
||||
```
|
||||
|
||||
### 重新构建
|
||||
|
||||
```bash
|
||||
docker compose --profile gateway build --no-cache
|
||||
docker compose --profile gateway up -d
|
||||
|
||||
```
|
||||
|
||||
### 🚀 快速开始
|
||||
|
||||
> [!TIP]
|
||||
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
|
||||
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||
> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
|
||||
|
||||
**1. 初始化 (Initialize)**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
|
||||
```
|
||||
|
||||
**2. 配置 (Configure)** (`~/.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": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**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)
|
||||
* **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
|
||||
|
||||
> **注意**: 完整的配置模板请参考 `config.example.json`。
|
||||
|
||||
**4. 对话 (Chat)**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "2+2 等于几?"
|
||||
|
||||
```
|
||||
|
||||
就是这样!您在 2 分钟内就拥有了一个可工作的 AI 助手。
|
||||
|
||||
---
|
||||
|
||||
## 💬 聊天应用集成 (Chat Apps)
|
||||
|
||||
通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话。
|
||||
|
||||
| 渠道 | 设置难度 |
|
||||
| --- | --- |
|
||||
| **Telegram** | 简单 (仅需 token) |
|
||||
| **Discord** | 简单 (bot token + intents) |
|
||||
| **QQ** | 简单 (AppID + AppSecret) |
|
||||
| **钉钉 (DingTalk)** | 中等 (app credentials) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b> (推荐)</summary>
|
||||
|
||||
**1. 创建机器人**
|
||||
|
||||
* 打开 Telegram,搜索 `@BotFather`
|
||||
* 发送 `/newbot`,按照提示操作
|
||||
* 复制 token
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
> 从 Telegram 上的 `@userinfobot` 获取您的用户 ID。
|
||||
|
||||
**3. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
**1. 创建机器人**
|
||||
|
||||
* 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications)
|
||||
* Create an application → Bot → Add Bot
|
||||
* 复制 bot token
|
||||
|
||||
**2. 开启 Intents**
|
||||
|
||||
* 在 Bot 设置中,开启 **MESSAGE CONTENT INTENT**
|
||||
* (可选) 如果计划基于成员数据使用白名单,开启 **SERVER MEMBERS INTENT**
|
||||
|
||||
**3. 获取您的 User ID**
|
||||
|
||||
* Discord 设置 → Advanced → 开启 **Developer Mode**
|
||||
* 右键点击您的头像 → **Copy User ID**
|
||||
|
||||
**4. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**5. 邀请机器人**
|
||||
|
||||
* OAuth2 → URL Generator
|
||||
* Scopes: `bot`
|
||||
* Bot Permissions: `Send Messages`, `Read Message History`
|
||||
* 打开生成的邀请 URL,将机器人添加到您的服务器
|
||||
|
||||
**6. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
**1. 创建机器人**
|
||||
|
||||
* 前往 [QQ 开放平台](https://q.qq.com/#)
|
||||
* 创建应用 → 获取 **AppID** 和 **AppSecret**
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"qq": {
|
||||
"enabled": true,
|
||||
"app_id": "YOUR_APP_ID",
|
||||
"app_secret": "YOUR_APP_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
> 将 `allow_from` 设为空以允许所有用户,或指定 QQ 号以限制访问。
|
||||
|
||||
**3. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>钉钉 (DingTalk)</b></summary>
|
||||
|
||||
**1. 创建机器人**
|
||||
|
||||
* 前往 [开放平台](https://open.dingtalk.com/)
|
||||
* 创建内部应用
|
||||
* 复制 Client ID 和 Client Secret
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"enabled": true,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
> 将 `allow_from` 设为空以允许所有用户,或指定 ID 以限制访问。
|
||||
|
||||
**3. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||
|
||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||
|
||||
**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai**](https://clawdchat.ai)
|
||||
|
||||
## ⚙️ 配置详解
|
||||
|
||||
配置文件路径: `~/.picoclaw/config.json`
|
||||
|
||||
### 工作区布局 (Workspace Layout)
|
||||
|
||||
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
|
||||
|
||||
```
|
||||
~/.picoclaw/workspace/
|
||||
├── sessions/ # 对话会话和历史
|
||||
├── memory/ # 长期记忆 (MEMORY.md)
|
||||
├── state/ # 持久化状态 (最后一次频道等)
|
||||
├── cron/ # 定时任务数据库
|
||||
├── skills/ # 自定义技能
|
||||
├── AGENTS.md # Agent 行为指南
|
||||
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
|
||||
├── IDENTITY.md # Agent 身份设定
|
||||
├── SOUL.md # Agent 灵魂/性格
|
||||
├── TOOLS.md # 工具描述
|
||||
└── USER.md # 用户偏好
|
||||
|
||||
```
|
||||
|
||||
### 心跳 / 周期性任务 (Heartbeat)
|
||||
|
||||
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
|
||||
- Check my email for important messages
|
||||
- Review my calendar for upcoming events
|
||||
- Check the weather forecast
|
||||
|
||||
```
|
||||
|
||||
Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
|
||||
|
||||
#### 使用 Spawn 的异步任务
|
||||
|
||||
对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**:
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
|
||||
## Quick Tasks (respond directly)
|
||||
- Report current time
|
||||
|
||||
## Long Tasks (use spawn for async)
|
||||
- Search the web for AI news and summarize
|
||||
- Check email and report important messages
|
||||
|
||||
```
|
||||
|
||||
**关键行为:**
|
||||
|
||||
| 特性 | 描述 |
|
||||
| --- | --- |
|
||||
| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
|
||||
| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
|
||||
| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
|
||||
| **非阻塞** | spawn 后,心跳继续处理下一个任务 |
|
||||
|
||||
#### 子 Agent 通信原理
|
||||
|
||||
```
|
||||
心跳触发 (Heartbeat triggers)
|
||||
↓
|
||||
Agent 读取 HEARTBEAT.md
|
||||
↓
|
||||
对于长任务: spawn 子 Agent
|
||||
↓ ↓
|
||||
继续下一个任务 子 Agent 独立工作
|
||||
↓ ↓
|
||||
所有任务完成 子 Agent 使用 "message" 工具
|
||||
↓ ↓
|
||||
响应 HEARTBEAT_OK 用户直接收到结果
|
||||
|
||||
```
|
||||
|
||||
子 Agent 可以访问工具(message, web_search 等),并且无需通过主 Agent 即可独立与用户通信。
|
||||
|
||||
**配置:**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
| 选项 | 默认值 | 描述 |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | `true` | 启用/禁用心跳 |
|
||||
| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
|
||||
|
||||
**环境变量:**
|
||||
|
||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
|
||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
|
||||
|
||||
### 提供商 (Providers)
|
||||
|
||||
> [!NOTE]
|
||||
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
|
||||
|
||||
| 提供商 | 用途 | 获取 API Key |
|
||||
| --- | --- | --- |
|
||||
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](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) |
|
||||
|
||||
<details>
|
||||
<summary><b>智谱 (Zhipu) 配置示例</b></summary>
|
||||
|
||||
**1. 获取 API key 和 base URL**
|
||||
|
||||
* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```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. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "你好"
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>完整配置示例</b></summary>
|
||||
|
||||
```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": {
|
||||
"search": {
|
||||
"api_key": "BSA..."
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## CLI 命令行参考
|
||||
|
||||
| 命令 | 描述 |
|
||||
| --- | --- |
|
||||
| `picoclaw onboard` | 初始化配置和工作区 |
|
||||
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
||||
| `picoclaw agent` | 交互式聊天模式 |
|
||||
| `picoclaw gateway` | 启动网关 (Gateway) |
|
||||
| `picoclaw status` | 显示状态 |
|
||||
| `picoclaw cron list` | 列出所有定时任务 |
|
||||
| `picoclaw cron add ...` | 添加定时任务 |
|
||||
|
||||
### 定时任务 / 提醒 (Scheduled Tasks)
|
||||
|
||||
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
||||
|
||||
* **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
|
||||
* **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
|
||||
* **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
|
||||
|
||||
任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。
|
||||
|
||||
## 🤝 贡献与路线图 (Roadmap)
|
||||
|
||||
欢迎提交 PR!代码库刻意保持小巧和可读。🤗
|
||||
|
||||
路线图即将发布...
|
||||
|
||||
开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。
|
||||
|
||||
用户群组:
|
||||
|
||||
Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
||||
|
||||
<img src="assets/wechat.png" alt="PicoClaw" width="512">
|
||||
|
||||
## 🐛 疑难解答 (Troubleshooting)
|
||||
|
||||
### 网络搜索提示 "API 配置问题"
|
||||
|
||||
如果您尚未配置搜索 API Key,这是正常的。PicoClaw 会提供手动搜索的帮助链接。
|
||||
|
||||
启用网络搜索:
|
||||
|
||||
1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
|
||||
2. 添加到 `~/.picoclaw/config.json`:
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
|
||||
### 遇到内容过滤错误 (Content Filtering Errors)
|
||||
|
||||
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
|
||||
|
||||
### Telegram bot 提示 "Conflict: terminated by other getUpdates"
|
||||
|
||||
这表示有另一个机器人实例正在运行。请确保同一时间只有一个 `picoclaw gateway` 进程在运行。
|
||||
|
||||
---
|
||||
|
||||
## 📝 API Key 对比
|
||||
|
||||
| 服务 | 免费层级 | 适用场景 |
|
||||
| --- | --- | --- |
|
||||
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
||||
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
||||
116
ROADMAP.md
Normal file
116
ROADMAP.md
Normal file
|
|
@ -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!
|
||||
BIN
assets/clawdchat-icon.png
Normal file
BIN
assets/clawdchat-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 307 KiB |
BIN
assets/termux.jpg
Normal file
BIN
assets/termux.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 141 KiB |
|
|
@ -9,8 +9,11 @@ package main
|
|||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
|
|
@ -25,32 +28,58 @@ import (
|
|||
"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/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 = "0.1.0"
|
||||
version = "dev"
|
||||
gitCommit string
|
||||
buildTime string
|
||||
goVersion string
|
||||
)
|
||||
|
||||
const logo = "🦞"
|
||||
|
||||
func printVersion() {
|
||||
fmt.Printf("%s picoclaw v%s\n", logo, version)
|
||||
if buildTime != "" {
|
||||
fmt.Printf(" Build: %s\n", buildTime)
|
||||
// formatVersion returns the version string with optional git commit
|
||||
func formatVersion() string {
|
||||
v := version
|
||||
if gitCommit != "" {
|
||||
v += fmt.Sprintf(" (git: %s)", gitCommit)
|
||||
}
|
||||
goVer := goVersion
|
||||
return v
|
||||
}
|
||||
|
||||
// formatBuildInfo returns build time and go version info
|
||||
func formatBuildInfo() (build string, goVer string) {
|
||||
if buildTime != "" {
|
||||
build = buildTime
|
||||
}
|
||||
goVer = goVersion
|
||||
if goVer == "" {
|
||||
goVer = runtime.Version()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func printVersion() {
|
||||
fmt.Printf("%s picoclaw %s\n", logo, formatVersion())
|
||||
build, goVer := formatBuildInfo()
|
||||
if build != "" {
|
||||
fmt.Printf(" Build: %s\n", build)
|
||||
}
|
||||
if goVer != "" {
|
||||
fmt.Printf(" Go: %s\n", goVer)
|
||||
}
|
||||
|
|
@ -208,10 +237,6 @@ func onboard() {
|
|||
}
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
os.MkdirAll(workspace, 0755)
|
||||
os.MkdirAll(filepath.Join(workspace, "memory"), 0755)
|
||||
os.MkdirAll(filepath.Join(workspace, "skills"), 0755)
|
||||
|
||||
createWorkspaceTemplates(workspace)
|
||||
|
||||
fmt.Printf("%s picoclaw is ready!\n", logo)
|
||||
|
|
@ -221,170 +246,57 @@ func onboard() {
|
|||
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) {
|
||||
templates := map[string]string{
|
||||
"AGENTS.md": `# Agent Instructions
|
||||
|
||||
You are a helpful AI assistant. Be concise, accurate, and friendly.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Always explain what you're doing before taking actions
|
||||
- Ask for clarification when request is ambiguous
|
||||
- Use tools to help accomplish tasks
|
||||
- Remember important information in your memory files
|
||||
- Be proactive and helpful
|
||||
- Learn from user feedback
|
||||
`,
|
||||
"SOUL.md": `# Soul
|
||||
|
||||
I am picoclaw, a lightweight AI assistant powered by AI.
|
||||
|
||||
## Personality
|
||||
|
||||
- Helpful and friendly
|
||||
- Concise and to the point
|
||||
- Curious and eager to learn
|
||||
- Honest and transparent
|
||||
|
||||
## Values
|
||||
|
||||
- Accuracy over speed
|
||||
- User privacy and safety
|
||||
- Transparency in actions
|
||||
- Continuous improvement
|
||||
`,
|
||||
"USER.md": `# User
|
||||
|
||||
Information about user goes here.
|
||||
|
||||
## Preferences
|
||||
|
||||
- Communication style: (casual/formal)
|
||||
- Timezone: (your timezone)
|
||||
- Language: (your preferred language)
|
||||
|
||||
## Personal Information
|
||||
|
||||
- Name: (optional)
|
||||
- Location: (optional)
|
||||
- Occupation: (optional)
|
||||
|
||||
## Learning Goals
|
||||
|
||||
- What the user wants to learn from AI
|
||||
- Preferred interaction style
|
||||
- Areas of interest
|
||||
`,
|
||||
"IDENTITY.md": `# Identity
|
||||
|
||||
## Name
|
||||
PicoClaw 🦞
|
||||
|
||||
## Description
|
||||
Ultra-lightweight personal AI assistant written in Go, inspired by nanobot.
|
||||
|
||||
## Version
|
||||
0.1.0
|
||||
|
||||
## Purpose
|
||||
- Provide intelligent AI assistance with minimal resource usage
|
||||
- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.)
|
||||
- Enable easy customization through skills system
|
||||
- Run on minimal hardware ($10 boards, <10MB RAM)
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Web search and content fetching
|
||||
- File system operations (read, write, edit)
|
||||
- Shell command execution
|
||||
- Multi-channel messaging (Telegram, WhatsApp, Feishu)
|
||||
- Skill-based extensibility
|
||||
- Memory and context management
|
||||
|
||||
## Philosophy
|
||||
|
||||
- Simplicity over complexity
|
||||
- Performance over features
|
||||
- User control and privacy
|
||||
- Transparent operation
|
||||
- Community-driven development
|
||||
|
||||
## Goals
|
||||
|
||||
- Provide a fast, lightweight AI assistant
|
||||
- Support offline-first operation where possible
|
||||
- Enable easy customization and extension
|
||||
- Maintain high quality responses
|
||||
- Run efficiently on constrained hardware
|
||||
|
||||
## License
|
||||
MIT License - Free and open source
|
||||
|
||||
## Repository
|
||||
https://github.com/sipeed/picoclaw
|
||||
|
||||
## Contact
|
||||
Issues: https://github.com/sipeed/picoclaw/issues
|
||||
Discussions: https://github.com/sipeed/picoclaw/discussions
|
||||
|
||||
---
|
||||
|
||||
"Every bit helps, every bit matters."
|
||||
- Picoclaw
|
||||
`,
|
||||
}
|
||||
|
||||
for filename, content := range templates {
|
||||
filePath := filepath.Join(workspace, filename)
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
os.WriteFile(filePath, []byte(content), 0644)
|
||||
fmt.Printf(" Created %s\n", filename)
|
||||
}
|
||||
}
|
||||
|
||||
memoryDir := filepath.Join(workspace, "memory")
|
||||
os.MkdirAll(memoryDir, 0755)
|
||||
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
|
||||
if _, err := os.Stat(memoryFile); os.IsNotExist(err) {
|
||||
memoryContent := `# Long-term Memory
|
||||
|
||||
This file stores important information that should persist across sessions.
|
||||
|
||||
## User Information
|
||||
|
||||
(Important facts about user)
|
||||
|
||||
## Preferences
|
||||
|
||||
(User preferences learned over time)
|
||||
|
||||
## Important Notes
|
||||
|
||||
(Things to remember)
|
||||
|
||||
## Configuration
|
||||
|
||||
- Model preferences
|
||||
- Channel settings
|
||||
- Skills enabled
|
||||
`
|
||||
os.WriteFile(memoryFile, []byte(memoryContent), 0644)
|
||||
fmt.Println(" Created memory/MEMORY.md")
|
||||
|
||||
skillsDir := filepath.Join(workspace, "skills")
|
||||
if _, err := os.Stat(skillsDir); os.IsNotExist(err) {
|
||||
os.MkdirAll(skillsDir, 0755)
|
||||
fmt.Println(" Created skills/")
|
||||
}
|
||||
}
|
||||
|
||||
for filename, content := range templates {
|
||||
filePath := filepath.Join(workspace, filename)
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
os.WriteFile(filePath, []byte(content), 0644)
|
||||
fmt.Printf(" Created %s\n", filename)
|
||||
}
|
||||
err := copyEmbeddedToTarget(workspace)
|
||||
if err != nil {
|
||||
fmt.Printf("Error copying workspace templates: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -650,14 +562,32 @@ func gatewayCmd() {
|
|||
})
|
||||
|
||||
// Setup cron tool and service
|
||||
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath())
|
||||
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(),
|
||||
nil,
|
||||
30*60,
|
||||
true,
|
||||
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 {
|
||||
|
|
@ -665,6 +595,9 @@ func gatewayCmd() {
|
|||
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)
|
||||
|
|
@ -715,10 +648,30 @@ func gatewayCmd() {
|
|||
}
|
||||
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)
|
||||
|
|
@ -727,6 +680,8 @@ func gatewayCmd() {
|
|||
|
||||
fmt.Println("\nShutting down...")
|
||||
cancel()
|
||||
healthServer.Stop(context.Background())
|
||||
deviceService.Stop()
|
||||
heartbeatService.Stop()
|
||||
cronService.Stop()
|
||||
agentLoop.Stop()
|
||||
|
|
@ -743,7 +698,13 @@ func statusCmd() {
|
|||
|
||||
configPath := getConfigPath()
|
||||
|
||||
fmt.Printf("%s picoclaw Status\n\n", logo)
|
||||
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, "✓")
|
||||
|
|
@ -1027,14 +988,14 @@ func getConfigPath() string {
|
|||
return filepath.Join(home, ".picoclaw", "config.json")
|
||||
}
|
||||
|
||||
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string) *cron.CronService {
|
||||
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *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)
|
||||
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, config)
|
||||
agentLoop.RegisterTool(cronTool)
|
||||
|
||||
// Set the onJob handler
|
||||
|
|
@ -1264,53 +1225,6 @@ func cronEnableCmd(storePath string, disable bool) {
|
|||
}
|
||||
}
|
||||
|
||||
func skillsCmd() {
|
||||
if len(os.Args) < 3 {
|
||||
skillsHelp()
|
||||
return
|
||||
}
|
||||
|
||||
subcommand := os.Args[2]
|
||||
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
workspace := cfg.WorkspacePath()
|
||||
installer := skills.NewSkillInstaller(workspace)
|
||||
// 获取全局配置目录和内置 skills 目录
|
||||
globalDir := filepath.Dir(getConfigPath())
|
||||
globalSkillsDir := filepath.Join(globalDir, "skills")
|
||||
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
||||
skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
|
||||
|
||||
switch subcommand {
|
||||
case "list":
|
||||
skillsListCmd(skillsLoader)
|
||||
case "install":
|
||||
skillsInstallCmd(installer)
|
||||
case "remove", "uninstall":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw skills remove <skill-name>")
|
||||
return
|
||||
}
|
||||
skillsRemoveCmd(installer, os.Args[3])
|
||||
case "search":
|
||||
skillsSearchCmd(installer)
|
||||
case "show":
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: picoclaw skills show <skill-name>")
|
||||
return
|
||||
}
|
||||
skillsShowCmd(skillsLoader, os.Args[3])
|
||||
default:
|
||||
fmt.Printf("Unknown skills command: %s\n", subcommand)
|
||||
skillsHelp()
|
||||
}
|
||||
}
|
||||
|
||||
func skillsHelp() {
|
||||
fmt.Println("\nSkills commands:")
|
||||
fmt.Println(" list List installed skills")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true,
|
||||
"model": "openrouter/auto",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
|
|
@ -12,7 +13,10 @@
|
|||
"telegram": {
|
||||
"enabled": false,
|
||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
"proxy": "",
|
||||
"allow_from": [
|
||||
"YOUR_USER_ID"
|
||||
]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": false,
|
||||
|
|
@ -49,6 +53,23 @@
|
|||
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
||||
"app_token": "xapp-YOUR-APP-TOKEN",
|
||||
"allow_from": []
|
||||
},
|
||||
"line": {
|
||||
"enabled": false,
|
||||
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_host": "0.0.0.0",
|
||||
"webhook_port": 18791,
|
||||
"webhook_path": "/webhook/line",
|
||||
"allow_from": []
|
||||
},
|
||||
"onebot": {
|
||||
"enabled": false,
|
||||
"ws_url": "ws://127.0.0.1:3001",
|
||||
"access_token": "",
|
||||
"reconnect_interval": 5,
|
||||
"group_trigger_prefix": [],
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
|
|
@ -58,7 +79,8 @@
|
|||
},
|
||||
"openai": {
|
||||
"api_key": "",
|
||||
"api_base": ""
|
||||
"api_base": "",
|
||||
"web_search": true
|
||||
},
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-xxx",
|
||||
|
|
@ -79,16 +101,46 @@
|
|||
"vllm": {
|
||||
"api_key": "",
|
||||
"api_base": ""
|
||||
},
|
||||
"nvidia": {
|
||||
"api_key": "nvapi-xxx",
|
||||
"api_base": "",
|
||||
"proxy": "http://127.0.0.1:7890"
|
||||
},
|
||||
"moonshot": {
|
||||
"api_key": "sk-xxx",
|
||||
"api_base": ""
|
||||
},
|
||||
"ollama": {
|
||||
"api_key": "",
|
||||
"api_base": "http://localhost:11434/v1"
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"perplexity": {
|
||||
"enabled": false,
|
||||
"api_key": "pplx-xxx",
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
},
|
||||
"devices": {
|
||||
"enabled": false,
|
||||
"monitor_usb": true
|
||||
},
|
||||
"gateway": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 18790
|
||||
40
docker-compose.yml
Normal file
40
docker-compose.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
services:
|
||||
# ─────────────────────────────────────────────
|
||||
# PicoClaw Agent (one-shot query)
|
||||
# docker compose run --rm picoclaw-agent -m "Hello"
|
||||
# ─────────────────────────────────────────────
|
||||
picoclaw-agent:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: picoclaw-agent
|
||||
profiles:
|
||||
- agent
|
||||
volumes:
|
||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
||||
entrypoint: ["picoclaw", "agent"]
|
||||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# PicoClaw Gateway (Long-running Bot)
|
||||
# docker compose up picoclaw-gateway
|
||||
# ─────────────────────────────────────────────
|
||||
picoclaw-gateway:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: picoclaw-gateway
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- gateway
|
||||
volumes:
|
||||
# Configuration file
|
||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
||||
# Persistent workspace (sessions, memory, logs)
|
||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
||||
command: ["gateway"]
|
||||
|
||||
volumes:
|
||||
picoclaw-workspace:
|
||||
112
docs/picoclaw_community_roadmap_260216.md
Normal file
112
docs/picoclaw_community_roadmap_260216.md
Normal file
|
|
@ -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!** 🐎
|
||||
122
docs/tools_configuration.md
Normal file
122
docs/tools_configuration.md
Normal file
|
|
@ -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_<SECTION>_<KEY>`:
|
||||
|
||||
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.
|
||||
11
go.mod
11
go.mod
|
|
@ -13,20 +13,29 @@ require (
|
|||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||
github.com/mymmrac/telego v1.6.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/openai/openai-go/v3 v3.21.0
|
||||
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
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
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/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
|
||||
|
|
|
|||
14
go.sum
14
go.sum
|
|
@ -32,6 +32,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
|||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
||||
github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
|
||||
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
|
||||
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
|
||||
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
|
||||
|
|
@ -56,6 +58,10 @@ 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=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
|
|
@ -74,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=
|
||||
|
|
@ -92,12 +100,13 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
|
|||
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/openai/openai-go/v3 v3.21.0 h1:3GpIR/W4q/v1uUOVuK3zYtQiF3DnRrZag/sxbtvEdtc=
|
||||
github.com/openai/openai-go/v3 v3.21.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
||||
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
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=
|
||||
|
|
@ -238,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=
|
||||
|
|
|
|||
|
|
@ -189,6 +189,17 @@ 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 ---
|
||||
|
||||
messages = append(messages, providers.Message{
|
||||
Role: "system",
|
||||
Content: systemPrompt,
|
||||
|
|
|
|||
145
pkg/agent/instance.go
Normal file
145
pkg/agent/instance.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
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
|
||||
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
|
||||
}
|
||||
|
||||
// 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,
|
||||
ContextWindow: defaults.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
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
630
pkg/agent/loop_test.go
Normal file
630
pkg/agent/loop_test.go
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"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-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create test config
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create agent loop
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &mockProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Test RecordLastChannel
|
||||
testChannel := "test-channel"
|
||||
err = al.RecordLastChannel(testChannel)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordLastChannel failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify channel was saved
|
||||
lastChannel := al.state.GetLastChannel()
|
||||
if lastChannel != testChannel {
|
||||
t.Errorf("Expected channel '%s', got '%s'", testChannel, lastChannel)
|
||||
}
|
||||
|
||||
// Verify persistence by creating a new agent loop
|
||||
al2 := NewAgentLoop(cfg, msgBus, provider)
|
||||
if al2.state.GetLastChannel() != testChannel {
|
||||
t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, al2.state.GetLastChannel())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordLastChatID(t *testing.T) {
|
||||
// Create temp workspace
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create test config
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create agent loop
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &mockProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Test RecordLastChatID
|
||||
testChatID := "test-chat-id-123"
|
||||
err = al.RecordLastChatID(testChatID)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordLastChatID failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify chat ID was saved
|
||||
lastChatID := al.state.GetLastChatID()
|
||||
if lastChatID != testChatID {
|
||||
t.Errorf("Expected chat ID '%s', got '%s'", testChatID, lastChatID)
|
||||
}
|
||||
|
||||
// Verify persistence by creating a new agent loop
|
||||
al2 := NewAgentLoop(cfg, msgBus, provider)
|
||||
if al2.state.GetLastChatID() != testChatID {
|
||||
t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, al2.state.GetLastChatID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAgentLoop_StateInitialized(t *testing.T) {
|
||||
// Create temp workspace
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Create test config
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
Workspace: tmpDir,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create agent loop
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &mockProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Verify state manager is initialized
|
||||
if al.state == nil {
|
||||
t.Error("Expected state manager to be initialized")
|
||||
}
|
||||
|
||||
// Verify state directory was created
|
||||
stateDir := filepath.Join(tmpDir, "state")
|
||||
if _, err := os.Stat(stateDir); os.IsNotExist(err) {
|
||||
t.Error("Expected state directory to exist")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolRegistry_ToolRegistration verifies tools can be registered and retrieved
|
||||
func TestToolRegistry_ToolRegistration(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()
|
||||
provider := &mockProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Register a custom tool
|
||||
customTool := &mockCustomTool{}
|
||||
al.RegisterTool(customTool)
|
||||
|
||||
// Verify tool is registered by checking it doesn't panic on GetStartupInfo
|
||||
// (actual tool retrieval is tested in tools package tests)
|
||||
info := al.GetStartupInfo()
|
||||
toolsInfo := info["tools"].(map[string]interface{})
|
||||
toolsList := toolsInfo["names"].([]string)
|
||||
|
||||
// Check that our custom tool name is in the list
|
||||
found := false
|
||||
for _, name := range toolsList {
|
||||
if name == "mock_custom" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Expected custom tool to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolContext_Updates verifies tool context is updated with channel/chatID
|
||||
func TestToolContext_Updates(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()
|
||||
provider := &simpleMockProvider{response: "OK"}
|
||||
_ = NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Verify that ContextualTool interface is defined and can be implemented
|
||||
// This test validates the interface contract exists
|
||||
ctxTool := &mockContextualTool{}
|
||||
|
||||
// Verify the tool implements the interface correctly
|
||||
var _ tools.ContextualTool = ctxTool
|
||||
}
|
||||
|
||||
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved
|
||||
func TestToolRegistry_GetDefinitions(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()
|
||||
provider := &mockProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Register a test tool and verify it shows up in startup info
|
||||
testTool := &mockCustomTool{}
|
||||
al.RegisterTool(testTool)
|
||||
|
||||
info := al.GetStartupInfo()
|
||||
toolsInfo := info["tools"].(map[string]interface{})
|
||||
toolsList := toolsInfo["names"].([]string)
|
||||
|
||||
// Check that our custom tool name is in the list
|
||||
found := false
|
||||
for _, name := range toolsList {
|
||||
if name == "mock_custom" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Expected custom tool to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentLoop_GetStartupInfo verifies startup info contains tools
|
||||
func TestAgentLoop_GetStartupInfo(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()
|
||||
provider := &mockProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
info := al.GetStartupInfo()
|
||||
|
||||
// Verify tools info exists
|
||||
toolsInfo, ok := info["tools"]
|
||||
if !ok {
|
||||
t.Fatal("Expected 'tools' key in startup info")
|
||||
}
|
||||
|
||||
toolsMap, ok := toolsInfo.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("Expected 'tools' to be a map")
|
||||
}
|
||||
|
||||
count, ok := toolsMap["count"]
|
||||
if !ok {
|
||||
t.Fatal("Expected 'count' in tools info")
|
||||
}
|
||||
|
||||
// Should have default tools registered
|
||||
if count.(int) == 0 {
|
||||
t.Error("Expected at least some tools to be registered")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAgentLoop_Stop verifies Stop() sets running to false
|
||||
func TestAgentLoop_Stop(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()
|
||||
provider := &mockProvider{}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Note: running is only set to true when Run() is called
|
||||
// We can't test that without starting the event loop
|
||||
// Instead, verify the Stop method can be called safely
|
||||
al.Stop()
|
||||
|
||||
// Verify running is false (initial state or after Stop)
|
||||
if al.running.Load() {
|
||||
t.Error("Expected agent to be stopped (or never started)")
|
||||
}
|
||||
}
|
||||
|
||||
// Mock implementations for testing
|
||||
|
||||
type simpleMockProvider struct {
|
||||
response string
|
||||
}
|
||||
|
||||
func (m *simpleMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
|
||||
return &providers.LLMResponse{
|
||||
Content: m.response,
|
||||
ToolCalls: []providers.ToolCall{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *simpleMockProvider) GetDefaultModel() string {
|
||||
return "mock-model"
|
||||
}
|
||||
|
||||
// mockCustomTool is a simple mock tool for registration testing
|
||||
type mockCustomTool struct{}
|
||||
|
||||
func (m *mockCustomTool) Name() string {
|
||||
return "mock_custom"
|
||||
}
|
||||
|
||||
func (m *mockCustomTool) Description() string {
|
||||
return "Mock custom tool for testing"
|
||||
}
|
||||
|
||||
func (m *mockCustomTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockCustomTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
|
||||
return tools.SilentResult("Custom tool executed")
|
||||
}
|
||||
|
||||
// mockContextualTool tracks context updates
|
||||
type mockContextualTool struct {
|
||||
lastChannel string
|
||||
lastChatID string
|
||||
}
|
||||
|
||||
func (m *mockContextualTool) Name() string {
|
||||
return "mock_contextual"
|
||||
}
|
||||
|
||||
func (m *mockContextualTool) Description() string {
|
||||
return "Mock contextual tool"
|
||||
}
|
||||
|
||||
func (m *mockContextualTool) Parameters() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockContextualTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
|
||||
return tools.SilentResult("Contextual tool executed")
|
||||
}
|
||||
|
||||
func (m *mockContextualTool) SetContext(channel, chatID string) {
|
||||
m.lastChannel = channel
|
||||
m.lastChatID = chatID
|
||||
}
|
||||
|
||||
// testHelper executes a message and returns the response
|
||||
type testHelper struct {
|
||||
al *AgentLoop
|
||||
}
|
||||
|
||||
func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, msg bus.InboundMessage) string {
|
||||
// Use a short timeout to avoid hanging
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout)
|
||||
defer cancel()
|
||||
|
||||
response, err := h.al.processMessage(timeoutCtx, msg)
|
||||
if err != nil {
|
||||
tb.Fatalf("processMessage failed: %v", err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
const responseTimeout = 3 * time.Second
|
||||
|
||||
// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound
|
||||
func TestToolResult_SilentToolDoesNotSendUserMessage(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()
|
||||
provider := &simpleMockProvider{response: "File operation complete"}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
helper := testHelper{al: al}
|
||||
|
||||
// ReadFileTool returns SilentResult, which should not send user message
|
||||
ctx := context.Background()
|
||||
msg := bus.InboundMessage{
|
||||
Channel: "test",
|
||||
SenderID: "user1",
|
||||
ChatID: "chat1",
|
||||
Content: "read test.txt",
|
||||
SessionKey: "test-session",
|
||||
}
|
||||
|
||||
response := helper.executeAndGetResponse(t, ctx, msg)
|
||||
|
||||
// Silent tool should return the LLM's response directly
|
||||
if response != "File operation complete" {
|
||||
t.Errorf("Expected 'File operation complete', got: %s", response)
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolResult_UserFacingToolDoesSendMessage verifies user-facing tools trigger outbound
|
||||
func TestToolResult_UserFacingToolDoesSendMessage(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()
|
||||
provider := &simpleMockProvider{response: "Command output: hello world"}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
helper := testHelper{al: al}
|
||||
|
||||
// ExecTool returns UserResult, which should send user message
|
||||
ctx := context.Background()
|
||||
msg := bus.InboundMessage{
|
||||
Channel: "test",
|
||||
SenderID: "user1",
|
||||
ChatID: "chat1",
|
||||
Content: "run hello",
|
||||
SessionKey: "test-session",
|
||||
}
|
||||
|
||||
response := helper.executeAndGetResponse(t, ctx, msg)
|
||||
|
||||
// User-facing tool should include the output in final response
|
||||
if response != "Command output: hello world" {
|
||||
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))
|
||||
}
|
||||
}
|
||||
114
pkg/agent/registry.go
Normal file
114
pkg/agent/registry.go
Normal file
|
|
@ -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
|
||||
}
|
||||
199
pkg/agent/registry_test.go
Normal file
199
pkg/agent/registry_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ type OAuthProviderConfig struct {
|
|||
Issuer string
|
||||
ClientID string
|
||||
Scopes string
|
||||
Originator string
|
||||
Port int
|
||||
}
|
||||
|
||||
|
|
@ -30,6 +31,7 @@ func OpenAIOAuthConfig() OAuthProviderConfig {
|
|||
Issuer: "https://auth.openai.com",
|
||||
ClientID: "app_EMoamEEZ73f0CkXaXp7hrann",
|
||||
Scopes: "openid profile email offline_access",
|
||||
Originator: "codex_cli_rs",
|
||||
Port: 1455,
|
||||
}
|
||||
}
|
||||
|
|
@ -279,7 +281,17 @@ 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
|
||||
}
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
|
||||
|
|
@ -294,9 +306,17 @@ func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
|
|||
"scope": {cfg.Scopes},
|
||||
"code_challenge": {pkce.CodeChallenge},
|
||||
"code_challenge_method": {"S256"},
|
||||
"id_token_add_organizations": {"true"},
|
||||
"codex_cli_simplified_flow": {"true"},
|
||||
"state": {state},
|
||||
}
|
||||
return cfg.Issuer + "/authorize?" + params.Encode()
|
||||
if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") {
|
||||
params.Set("originator", "picoclaw")
|
||||
}
|
||||
if cfg.Originator != "" {
|
||||
params.Set("originator", cfg.Originator)
|
||||
}
|
||||
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
|
||||
}
|
||||
|
||||
func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
|
||||
|
|
@ -350,19 +370,57 @@ 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.
|
||||
cred.AccountID = accountID
|
||||
}
|
||||
|
||||
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:
|
||||
|
|
@ -373,21 +431,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) {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,33 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"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",
|
||||
ClientID: "test-client-id",
|
||||
Scopes: "openid profile",
|
||||
Originator: "codex_cli_rs",
|
||||
Port: 1455,
|
||||
}
|
||||
pkce := PKCECodes{
|
||||
|
|
@ -22,7 +37,7 @@ func TestBuildAuthorizeURL(t *testing.T) {
|
|||
|
||||
u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback")
|
||||
|
||||
if !strings.HasPrefix(u, "https://auth.example.com/authorize?") {
|
||||
if !strings.HasPrefix(u, "https://auth.example.com/oauth/authorize?") {
|
||||
t.Errorf("URL does not start with expected prefix: %s", u)
|
||||
}
|
||||
if !strings.Contains(u, "client_id=test-client-id") {
|
||||
|
|
@ -40,6 +55,37 @@ func TestBuildAuthorizeURL(t *testing.T) {
|
|||
if !strings.Contains(u, "response_type=code") {
|
||||
t.Error("URL missing response_type")
|
||||
}
|
||||
if !strings.Contains(u, "id_token_add_organizations=true") {
|
||||
t.Error("URL missing id_token_add_organizations")
|
||||
}
|
||||
if !strings.Contains(u, "codex_cli_simplified_flow=true") {
|
||||
t.Error("URL missing codex_cli_simplified_flow")
|
||||
}
|
||||
if !strings.Contains(u, "originator=codex_cli_rs") {
|
||||
t.Error("URL missing originator")
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -73,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")
|
||||
|
|
@ -81,6 +158,32 @@ func TestParseTokenResponseNoAccessToken(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
|
||||
idToken := makeJWTWithAccountID("acc-from-id")
|
||||
resp := map[string]interface{}{
|
||||
"access_token": "not-a-jwt",
|
||||
"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-from-id" {
|
||||
t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-from-id")
|
||||
}
|
||||
}
|
||||
|
||||
func makeJWTWithAccountID(accountID string) string {
|
||||
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
||||
payload := base64.RawURLEncoding.EncodeToString([]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`))
|
||||
return header + "." + payload + ".sig"
|
||||
}
|
||||
|
||||
func TestExchangeCodeForTokens(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/oauth/token" {
|
||||
|
|
@ -185,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" {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package channels
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
)
|
||||
|
|
@ -47,8 +47,33 @@ func (c *BaseChannel) IsAllowed(senderID string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// Extract parts from compound senderID like "123456|username"
|
||||
idPart := senderID
|
||||
userPart := ""
|
||||
if idx := strings.Index(senderID, "|"); idx > 0 {
|
||||
idPart = senderID[:idx]
|
||||
userPart = senderID[idx+1:]
|
||||
}
|
||||
|
||||
for _, allowed := range c.allowList {
|
||||
if senderID == allowed {
|
||||
// Strip leading "@" from allowed value for username matching
|
||||
trimmed := strings.TrimPrefix(allowed, "@")
|
||||
allowedID := trimmed
|
||||
allowedUser := ""
|
||||
if idx := strings.Index(trimmed, "|"); idx > 0 {
|
||||
allowedID = trimmed[:idx]
|
||||
allowedUser = trimmed[idx+1:]
|
||||
}
|
||||
|
||||
// Support either side using "id|username" compound form.
|
||||
// This keeps backward compatibility with legacy Telegram allowlist entries.
|
||||
if senderID == allowed ||
|
||||
idPart == allowed ||
|
||||
senderID == trimmed ||
|
||||
idPart == trimmed ||
|
||||
idPart == allowedID ||
|
||||
(allowedUser != "" && senderID == allowedUser) ||
|
||||
(userPart != "" && (userPart == allowed || userPart == trimmed || userPart == allowedUser)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -61,16 +86,12 @@ 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,
|
||||
}
|
||||
|
||||
|
|
|
|||
52
pkg/channels/base_test.go
Normal file
52
pkg/channels/base_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package channels
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBaseChannelIsAllowed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
allowList []string
|
||||
senderID string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty allowlist allows all",
|
||||
allowList: nil,
|
||||
senderID: "anyone",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "compound sender matches numeric allowlist",
|
||||
allowList: []string{"123456"},
|
||||
senderID: "123456|alice",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "compound sender matches username allowlist",
|
||||
allowList: []string{"@alice"},
|
||||
senderID: "123456|alice",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "numeric sender matches legacy compound allowlist",
|
||||
allowList: []string{"123456|alice"},
|
||||
senderID: "123456",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "non matching sender is denied",
|
||||
allowList: []string{"123456"},
|
||||
senderID: "654321|bob",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ch := NewBaseChannel("test", nil, nil, tt.allowList)
|
||||
if got := ch.IsAllowed(tt.senderID); got != tt.want {
|
||||
t.Fatalf("IsAllowed(%q) = %v, want %v", tt.senderID, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
|
|
@ -100,15 +101,156 @@ 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 := splitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks
|
||||
|
||||
for _, chunk := range chunks {
|
||||
if err := c.sendChunk(ctx, channelID, chunk); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitMessage splits long messages into chunks, preserving code block integrity
|
||||
// Uses natural boundaries (newlines, spaces) and extends messages slightly to avoid breaking code blocks
|
||||
func splitMessage(content string, limit int) []string {
|
||||
var messages []string
|
||||
|
||||
for len(content) > 0 {
|
||||
if len(content) <= limit {
|
||||
messages = append(messages, content)
|
||||
break
|
||||
}
|
||||
|
||||
msgEnd := limit
|
||||
|
||||
// Find natural split point within the limit
|
||||
msgEnd = findLastNewline(content[:limit], 200)
|
||||
if msgEnd <= 0 {
|
||||
msgEnd = findLastSpace(content[:limit], 100)
|
||||
}
|
||||
if msgEnd <= 0 {
|
||||
msgEnd = limit
|
||||
}
|
||||
|
||||
// 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 to include the closing ``` (with some buffer)
|
||||
extendedLimit := limit + 500 // Allow 500 char buffer for code blocks
|
||||
if len(content) > extendedLimit {
|
||||
closingIdx := findNextClosingCodeBlock(content, msgEnd)
|
||||
if closingIdx > 0 && closingIdx <= extendedLimit {
|
||||
// Extend to include the closing ```
|
||||
msgEnd = closingIdx
|
||||
} else {
|
||||
// Can't find closing, split before the code block
|
||||
msgEnd = findLastNewline(content[:unclosedIdx], 200)
|
||||
if msgEnd <= 0 {
|
||||
msgEnd = findLastSpace(content[:unclosedIdx], 100)
|
||||
}
|
||||
if msgEnd <= 0 {
|
||||
msgEnd = unclosedIdx
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Remaining content fits within extended limit
|
||||
msgEnd = len(content)
|
||||
}
|
||||
}
|
||||
|
||||
if msgEnd <= 0 {
|
||||
msgEnd = limit
|
||||
}
|
||||
|
||||
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 {
|
||||
count := 0
|
||||
lastOpenIdx := -1
|
||||
|
||||
for i := 0; i < len(text); i++ {
|
||||
if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
|
||||
if count == 0 {
|
||||
lastOpenIdx = i
|
||||
}
|
||||
count++
|
||||
i += 2
|
||||
}
|
||||
}
|
||||
|
||||
// If odd number of ``` markers, last one is unclosed
|
||||
if count%2 == 1 {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}()
|
||||
|
||||
|
|
@ -140,6 +282,12 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
|||
return
|
||||
}
|
||||
|
||||
if err := c.session.ChannelTyping(m.ChannelID); err != nil {
|
||||
logger.ErrorCF("discord", "Failed to send typing indicator", map[string]any{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
// 检查白名单,避免为被拒绝的用户下载附件和转录
|
||||
if !c.IsAllowed(m.Author.ID) {
|
||||
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
|
||||
|
|
@ -228,6 +376,13 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
|
|||
"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,6 +391,8 @@ 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)
|
||||
|
|
|
|||
36
pkg/channels/feishu_32.go
Normal file
36
pkg/channels/feishu_32.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64
|
||||
|
||||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// FeishuChannel is a stub implementation for 32-bit architectures
|
||||
type FeishuChannel struct {
|
||||
*BaseChannel
|
||||
}
|
||||
|
||||
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
|
||||
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
||||
return nil, errors.New("feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config")
|
||||
}
|
||||
|
||||
// Start is a stub method to satisfy the Channel interface
|
||||
func (c *FeishuChannel) Start(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop is a stub method to satisfy the Channel interface
|
||||
func (c *FeishuChannel) Stop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send is a stub method to satisfy the Channel interface
|
||||
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
return errors.New("feishu channel is not supported on 32-bit architectures")
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
||||
|
||||
package channels
|
||||
|
||||
import (
|
||||
598
pkg/channels/line.go
Normal file
598
pkg/channels/line.go
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
lineAPIBase = "https://api.line.me/v2/bot"
|
||||
lineDataAPIBase = "https://api-data.line.me/v2/bot"
|
||||
lineReplyEndpoint = lineAPIBase + "/message/reply"
|
||||
linePushEndpoint = lineAPIBase + "/message/push"
|
||||
lineContentEndpoint = lineDataAPIBase + "/message/%s/content"
|
||||
lineBotInfoEndpoint = lineAPIBase + "/info"
|
||||
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
|
||||
lineReplyTokenMaxAge = 25 * time.Second
|
||||
)
|
||||
|
||||
type replyTokenEntry struct {
|
||||
token string
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
// LINEChannel implements the Channel interface for LINE Official Account
|
||||
// using the LINE Messaging API with HTTP webhook for receiving messages
|
||||
// and REST API for sending messages.
|
||||
type LINEChannel struct {
|
||||
*BaseChannel
|
||||
config config.LINEConfig
|
||||
httpServer *http.Server
|
||||
botUserID string // Bot's user ID
|
||||
botBasicID string // Bot's basic ID (e.g. @216ru...)
|
||||
botDisplayName string // Bot's display name for text-based mention detection
|
||||
replyTokens sync.Map // chatID -> replyTokenEntry
|
||||
quoteTokens sync.Map // chatID -> quoteToken (string)
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// NewLINEChannel creates a new LINE channel instance.
|
||||
func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {
|
||||
if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" {
|
||||
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
|
||||
}
|
||||
|
||||
base := NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom)
|
||||
|
||||
return &LINEChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Start launches the HTTP webhook server.
|
||||
func (c *LINEChannel) Start(ctx context.Context) error {
|
||||
logger.InfoC("line", "Starting LINE channel (Webhook Mode)")
|
||||
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
// Fetch bot profile to get bot's userId for mention detection
|
||||
if err := c.fetchBotInfo(); err != nil {
|
||||
logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
logger.InfoCF("line", "Bot info fetched", map[string]interface{}{
|
||||
"bot_user_id": c.botUserID,
|
||||
"basic_id": c.botBasicID,
|
||||
"display_name": c.botDisplayName,
|
||||
})
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
path := c.config.WebhookPath
|
||||
if path == "" {
|
||||
path = "/webhook/line"
|
||||
}
|
||||
mux.HandleFunc(path, c.webhookHandler)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
|
||||
c.httpServer = &http.Server{
|
||||
Addr: addr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
go func() {
|
||||
logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{
|
||||
"addr": addr,
|
||||
"path": path,
|
||||
})
|
||||
if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.ErrorCF("line", "Webhook server error", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoC("line", "LINE channel started (Webhook Mode)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// fetchBotInfo retrieves the bot's userId, basicId, and displayName from the LINE API.
|
||||
func (c *LINEChannel) fetchBotInfo() error {
|
||||
req, err := http.NewRequest(http.MethodGet, lineBotInfoEndpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("bot info API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var info struct {
|
||||
UserID string `json:"userId"`
|
||||
BasicID string `json:"basicId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.botUserID = info.UserID
|
||||
c.botBasicID = info.BasicID
|
||||
c.botDisplayName = info.DisplayName
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the HTTP server.
|
||||
func (c *LINEChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("line", "Stopping LINE channel")
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
if c.httpServer != nil {
|
||||
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := c.httpServer.Shutdown(shutdownCtx); err != nil {
|
||||
logger.ErrorCF("line", "Webhook server shutdown error", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
c.setRunning(false)
|
||||
logger.InfoC("line", "LINE channel stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// webhookHandler handles incoming LINE webhook requests.
|
||||
func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
signature := r.Header.Get("X-Line-Signature")
|
||||
if !c.verifySignature(body, signature) {
|
||||
logger.WarnC("line", "Invalid webhook signature")
|
||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Events []lineEvent `json:"events"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Return 200 immediately, process events asynchronously
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
for _, event := range payload.Events {
|
||||
go c.processEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// verifySignature validates the X-Line-Signature using HMAC-SHA256.
|
||||
func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
|
||||
if signature == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret))
|
||||
mac.Write(body)
|
||||
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return hmac.Equal([]byte(expected), []byte(signature))
|
||||
}
|
||||
|
||||
// LINE webhook event types
|
||||
type lineEvent struct {
|
||||
Type string `json:"type"`
|
||||
ReplyToken string `json:"replyToken"`
|
||||
Source lineSource `json:"source"`
|
||||
Message json.RawMessage `json:"message"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type lineSource struct {
|
||||
Type string `json:"type"` // "user", "group", "room"
|
||||
UserID string `json:"userId"`
|
||||
GroupID string `json:"groupId"`
|
||||
RoomID string `json:"roomId"`
|
||||
}
|
||||
|
||||
type lineMessage struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "text", "image", "video", "audio", "file", "sticker"
|
||||
Text string `json:"text"`
|
||||
QuoteToken string `json:"quoteToken"`
|
||||
Mention *struct {
|
||||
Mentionees []lineMentionee `json:"mentionees"`
|
||||
} `json:"mention"`
|
||||
ContentProvider struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"contentProvider"`
|
||||
}
|
||||
|
||||
type lineMentionee struct {
|
||||
Index int `json:"index"`
|
||||
Length int `json:"length"`
|
||||
Type string `json:"type"` // "user", "all"
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
|
||||
func (c *LINEChannel) processEvent(event lineEvent) {
|
||||
if event.Type != "message" {
|
||||
logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{
|
||||
"type": event.Type,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
senderID := event.Source.UserID
|
||||
chatID := c.resolveChatID(event.Source)
|
||||
isGroup := event.Source.Type == "group" || event.Source.Type == "room"
|
||||
|
||||
var msg lineMessage
|
||||
if err := json.Unmarshal(event.Message, &msg); err != nil {
|
||||
logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// In group chats, only respond when the bot is mentioned
|
||||
if isGroup && !c.isBotMentioned(msg) {
|
||||
logger.DebugCF("line", "Ignoring group message without mention", map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Store reply token for later use
|
||||
if event.ReplyToken != "" {
|
||||
c.replyTokens.Store(chatID, replyTokenEntry{
|
||||
token: event.ReplyToken,
|
||||
timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// Store quote token for quoting the original message in reply
|
||||
if msg.QuoteToken != "" {
|
||||
c.quoteTokens.Store(chatID, msg.QuoteToken)
|
||||
}
|
||||
|
||||
var content string
|
||||
var mediaPaths []string
|
||||
localFiles := []string{}
|
||||
|
||||
defer func() {
|
||||
for _, file := range localFiles {
|
||||
if err := os.Remove(file); err != nil {
|
||||
logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{
|
||||
"file": file,
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
switch msg.Type {
|
||||
case "text":
|
||||
content = msg.Text
|
||||
// Strip bot mention from text in group chats
|
||||
if isGroup {
|
||||
content = c.stripBotMention(content, msg)
|
||||
}
|
||||
case "image":
|
||||
localPath := c.downloadContent(msg.ID, "image.jpg")
|
||||
if localPath != "" {
|
||||
localFiles = append(localFiles, localPath)
|
||||
mediaPaths = append(mediaPaths, localPath)
|
||||
content = "[image]"
|
||||
}
|
||||
case "audio":
|
||||
localPath := c.downloadContent(msg.ID, "audio.m4a")
|
||||
if localPath != "" {
|
||||
localFiles = append(localFiles, localPath)
|
||||
mediaPaths = append(mediaPaths, localPath)
|
||||
content = "[audio]"
|
||||
}
|
||||
case "video":
|
||||
localPath := c.downloadContent(msg.ID, "video.mp4")
|
||||
if localPath != "" {
|
||||
localFiles = append(localFiles, localPath)
|
||||
mediaPaths = append(mediaPaths, localPath)
|
||||
content = "[video]"
|
||||
}
|
||||
case "file":
|
||||
content = "[file]"
|
||||
case "sticker":
|
||||
content = "[sticker]"
|
||||
default:
|
||||
content = fmt.Sprintf("[%s]", msg.Type)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
metadata := map[string]string{
|
||||
"platform": "line",
|
||||
"source_type": event.Source.Type,
|
||||
"message_id": msg.ID,
|
||||
}
|
||||
|
||||
logger.DebugCF("line", "Received message", map[string]interface{}{
|
||||
"sender_id": senderID,
|
||||
"chat_id": chatID,
|
||||
"message_type": msg.Type,
|
||||
"is_group": isGroup,
|
||||
"preview": utils.Truncate(content, 50),
|
||||
})
|
||||
|
||||
// Show typing/loading indicator (requires user ID, not group ID)
|
||||
c.sendLoading(senderID)
|
||||
|
||||
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
|
||||
}
|
||||
|
||||
// isBotMentioned checks if the bot is mentioned in the message.
|
||||
// It first checks the mention metadata (userId match), then falls back
|
||||
// to text-based detection using the bot's display name, since LINE may
|
||||
// not include userId in mentionees for Official Accounts.
|
||||
func (c *LINEChannel) isBotMentioned(msg lineMessage) bool {
|
||||
// Check mention metadata
|
||||
if msg.Mention != nil {
|
||||
for _, m := range msg.Mention.Mentionees {
|
||||
if m.Type == "all" {
|
||||
return true
|
||||
}
|
||||
if c.botUserID != "" && m.UserID == c.botUserID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Mention metadata exists with mentionees but bot not matched by userId.
|
||||
// The bot IS likely mentioned (LINE includes mention struct when bot is @-ed),
|
||||
// so check if any mentionee overlaps with bot display name in text.
|
||||
if c.botDisplayName != "" {
|
||||
for _, m := range msg.Mention.Mentionees {
|
||||
if m.Index >= 0 && m.Length > 0 {
|
||||
runes := []rune(msg.Text)
|
||||
end := m.Index + m.Length
|
||||
if end <= len(runes) {
|
||||
mentionText := string(runes[m.Index:end])
|
||||
if strings.Contains(mentionText, c.botDisplayName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: text-based detection with display name
|
||||
if c.botDisplayName != "" && strings.Contains(msg.Text, "@"+c.botDisplayName) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// stripBotMention removes the @BotName mention text from the message.
|
||||
func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string {
|
||||
stripped := false
|
||||
|
||||
// Try to strip using mention metadata indices
|
||||
if msg.Mention != nil {
|
||||
runes := []rune(text)
|
||||
for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- {
|
||||
m := msg.Mention.Mentionees[i]
|
||||
// Strip if userId matches OR if the mention text contains the bot display name
|
||||
shouldStrip := false
|
||||
if c.botUserID != "" && m.UserID == c.botUserID {
|
||||
shouldStrip = true
|
||||
} else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 {
|
||||
end := m.Index + m.Length
|
||||
if end <= len(runes) {
|
||||
mentionText := string(runes[m.Index:end])
|
||||
if strings.Contains(mentionText, c.botDisplayName) {
|
||||
shouldStrip = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if shouldStrip {
|
||||
start := m.Index
|
||||
end := m.Index + m.Length
|
||||
if start >= 0 && end <= len(runes) {
|
||||
runes = append(runes[:start], runes[end:]...)
|
||||
stripped = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if stripped {
|
||||
return strings.TrimSpace(string(runes))
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: strip @DisplayName from text
|
||||
if c.botDisplayName != "" {
|
||||
text = strings.ReplaceAll(text, "@"+c.botDisplayName, "")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
// resolveChatID determines the chat ID from the event source.
|
||||
// For group/room messages, use the group/room ID; for 1:1, use the user ID.
|
||||
func (c *LINEChannel) resolveChatID(source lineSource) string {
|
||||
switch source.Type {
|
||||
case "group":
|
||||
return source.GroupID
|
||||
case "room":
|
||||
return source.RoomID
|
||||
default:
|
||||
return source.UserID
|
||||
}
|
||||
}
|
||||
|
||||
// Send sends a message to LINE. It first tries the Reply API (free)
|
||||
// using a cached reply token, then falls back to the Push API.
|
||||
func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return fmt.Errorf("line channel not running")
|
||||
}
|
||||
|
||||
// Load and consume quote token for this chat
|
||||
var quoteToken string
|
||||
if qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok {
|
||||
quoteToken = qt.(string)
|
||||
}
|
||||
|
||||
// Try reply token first (free, valid for ~25 seconds)
|
||||
if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok {
|
||||
tokenEntry := entry.(replyTokenEntry)
|
||||
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
|
||||
if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil {
|
||||
logger.DebugCF("line", "Message sent via Reply API", map[string]interface{}{
|
||||
"chat_id": msg.ChatID,
|
||||
"quoted": quoteToken != "",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
logger.DebugC("line", "Reply API failed, falling back to Push API")
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to Push API
|
||||
return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken)
|
||||
}
|
||||
|
||||
// buildTextMessage creates a text message object, optionally with quoteToken.
|
||||
func buildTextMessage(content, quoteToken string) map[string]string {
|
||||
msg := map[string]string{
|
||||
"type": "text",
|
||||
"text": content,
|
||||
}
|
||||
if quoteToken != "" {
|
||||
msg["quoteToken"] = quoteToken
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// sendReply sends a message using the LINE Reply API.
|
||||
func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error {
|
||||
payload := map[string]interface{}{
|
||||
"replyToken": replyToken,
|
||||
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
|
||||
}
|
||||
|
||||
return c.callAPI(ctx, lineReplyEndpoint, payload)
|
||||
}
|
||||
|
||||
// sendPush sends a message using the LINE Push API.
|
||||
func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error {
|
||||
payload := map[string]interface{}{
|
||||
"to": to,
|
||||
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
|
||||
}
|
||||
|
||||
return c.callAPI(ctx, linePushEndpoint, payload)
|
||||
}
|
||||
|
||||
// sendLoading sends a loading animation indicator to the chat.
|
||||
func (c *LINEChannel) sendLoading(chatID string) {
|
||||
payload := map[string]interface{}{
|
||||
"chatId": chatID,
|
||||
"loadingSeconds": 60,
|
||||
}
|
||||
if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil {
|
||||
logger.DebugCF("line", "Failed to send loading indicator", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// callAPI makes an authenticated POST request to the LINE API.
|
||||
func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("API request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("LINE API error (status %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadContent downloads media content from the LINE API.
|
||||
func (c *LINEChannel) downloadContent(messageID, filename string) string {
|
||||
url := fmt.Sprintf(lineContentEndpoint, messageID)
|
||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||
LoggerPrefix: "line",
|
||||
ExtraHeaders: map[string]string{
|
||||
"Authorization": "Bearer " + c.config.ChannelAccessToken,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/constants"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
|
|
@ -47,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(),
|
||||
|
|
@ -149,6 +150,32 @@ func (m *Manager) initChannels() error {
|
|||
}
|
||||
}
|
||||
|
||||
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
|
||||
logger.DebugC("channels", "Attempting to initialize LINE channel")
|
||||
line, err := NewLINEChannel(m.config.Channels.LINE, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
m.channels["line"] = line
|
||||
logger.InfoC("channels", "LINE channel enabled successfully")
|
||||
}
|
||||
}
|
||||
|
||||
if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" {
|
||||
logger.DebugC("channels", "Attempting to initialize OneBot channel")
|
||||
onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus)
|
||||
if err != nil {
|
||||
logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
m.channels["onebot"] = onebot
|
||||
logger.InfoC("channels", "OneBot channel enabled successfully")
|
||||
}
|
||||
}
|
||||
|
||||
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
||||
"enabled_channels": len(m.channels),
|
||||
})
|
||||
|
|
@ -229,6 +256,11 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
|
|||
continue
|
||||
}
|
||||
|
||||
// Silently skip internal channels
|
||||
if constants.IsInternalChannel(msg.Channel) {
|
||||
continue
|
||||
}
|
||||
|
||||
m.mu.RLock()
|
||||
channel, exists := m.channels[msg.Channel]
|
||||
m.mu.RUnlock()
|
||||
|
|
|
|||
686
pkg/channels/onebot.go
Normal file
686
pkg/channels/onebot.go
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
package channels
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type oneBotRawEvent struct {
|
||||
PostType string `json:"post_type"`
|
||||
MessageType string `json:"message_type"`
|
||||
SubType string `json:"sub_type"`
|
||||
MessageID json.RawMessage `json:"message_id"`
|
||||
UserID json.RawMessage `json:"user_id"`
|
||||
GroupID json.RawMessage `json:"group_id"`
|
||||
RawMessage string `json:"raw_message"`
|
||||
Message json.RawMessage `json:"message"`
|
||||
Sender json.RawMessage `json:"sender"`
|
||||
SelfID json.RawMessage `json:"self_id"`
|
||||
Time json.RawMessage `json:"time"`
|
||||
MetaEventType string `json:"meta_event_type"`
|
||||
Echo string `json:"echo"`
|
||||
RetCode json.RawMessage `json:"retcode"`
|
||||
Status BotStatus `json:"status"`
|
||||
}
|
||||
|
||||
type BotStatus struct {
|
||||
Online bool `json:"online"`
|
||||
Good bool `json:"good"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
|
||||
base := NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom)
|
||||
|
||||
const dedupSize = 1024
|
||||
return &OneBotChannel{
|
||||
BaseChannel: base,
|
||||
config: cfg,
|
||||
dedup: make(map[string]struct{}, dedupSize),
|
||||
dedupRing: make([]string, dedupSize),
|
||||
dedupIdx: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) Start(ctx context.Context) error {
|
||||
if c.config.WSUrl == "" {
|
||||
return fmt.Errorf("OneBot ws_url not configured")
|
||||
}
|
||||
|
||||
logger.InfoCF("onebot", "Starting OneBot channel", map[string]interface{}{
|
||||
"ws_url": c.config.WSUrl,
|
||||
})
|
||||
|
||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||
|
||||
if err := c.connect(); err != nil {
|
||||
logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
go c.listen()
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
c.setRunning(true)
|
||||
logger.InfoC("onebot", "OneBot channel started successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) connect() error {
|
||||
dialer := websocket.DefaultDialer
|
||||
dialer.HandshakeTimeout = 10 * time.Second
|
||||
|
||||
header := make(map[string][]string)
|
||||
if c.config.AccessToken != "" {
|
||||
header["Authorization"] = []string{"Bearer " + c.config.AccessToken}
|
||||
}
|
||||
|
||||
conn, _, err := dialer.Dial(c.config.WSUrl, header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
c.conn = conn
|
||||
c.mu.Unlock()
|
||||
|
||||
logger.InfoC("onebot", "WebSocket connected")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) reconnectLoop() {
|
||||
interval := time.Duration(c.config.ReconnectInterval) * time.Second
|
||||
if interval < 5*time.Second {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-time.After(interval):
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
c.mu.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
logger.InfoC("onebot", "Attempting to reconnect...")
|
||||
if err := c.connect(); err != nil {
|
||||
logger.ErrorCF("onebot", "Reconnect failed", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
} else {
|
||||
go c.listen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) Stop(ctx context.Context) error {
|
||||
logger.InfoC("onebot", "Stopping OneBot channel")
|
||||
c.setRunning(false)
|
||||
|
||||
if c.cancel != nil {
|
||||
c.cancel()
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.conn != nil {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||
if !c.IsRunning() {
|
||||
return fmt.Errorf("OneBot channel not running")
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
conn := c.conn
|
||||
c.mu.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
return fmt.Errorf("OneBot WebSocket not connected")
|
||||
}
|
||||
|
||||
action, params, err := c.buildSendRequest(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.writeMu.Lock()
|
||||
c.echoCounter++
|
||||
echo := fmt.Sprintf("send_%d", c.echoCounter)
|
||||
c.writeMu.Unlock()
|
||||
|
||||
req := oneBotAPIRequest{
|
||||
Action: action,
|
||||
Params: params,
|
||||
Echo: echo,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal OneBot request: %w", err)
|
||||
}
|
||||
|
||||
c.writeMu.Lock()
|
||||
err = conn.WriteMessage(websocket.TextMessage, data)
|
||||
c.writeMu.Unlock()
|
||||
|
||||
if err != nil {
|
||||
logger.ErrorCF("onebot", "Failed to send message", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) {
|
||||
chatID := msg.ChatID
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("invalid chatID for OneBot: %s", chatID)
|
||||
}
|
||||
|
||||
return "send_private_msg", oneBotSendPrivateMsgParams{
|
||||
UserID: userID,
|
||||
Message: msg.Content,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) listen() {
|
||||
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 {
|
||||
c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
logger.DebugCF("onebot", "Raw WebSocket message received", map[string]interface{}{
|
||||
"length": len(message),
|
||||
"payload": string(message),
|
||||
})
|
||||
|
||||
var raw oneBotRawEvent
|
||||
if err := json.Unmarshal(message, &raw); err != nil {
|
||||
logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"payload": string(message),
|
||||
})
|
||||
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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
|
||||
c.handleRawEvent(&raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseJSONInt64(raw json.RawMessage) (int64, error) {
|
||||
if len(raw) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var n int64
|
||||
if err := json.Unmarshal(raw, &n); err == nil {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
return 0, fmt.Errorf("cannot parse as int64: %s", string(raw))
|
||||
}
|
||||
|
||||
func parseJSONString(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
type parseMessageResult struct {
|
||||
Text string
|
||||
IsBotMentioned bool
|
||||
}
|
||||
|
||||
func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult {
|
||||
if len(raw) == 0 {
|
||||
return parseMessageResult{}
|
||||
}
|
||||
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
mentioned := false
|
||||
if selfID > 0 {
|
||||
cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID)
|
||||
if strings.Contains(s, cqAt) {
|
||||
mentioned = true
|
||||
s = strings.ReplaceAll(s, cqAt, "")
|
||||
s = strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
return parseMessageResult{Text: s, IsBotMentioned: mentioned}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
case "at":
|
||||
if data != nil && selfID > 0 {
|
||||
qqVal := fmt.Sprintf("%v", data["qq"])
|
||||
if qqVal == selfIDStr || qqVal == "all" {
|
||||
mentioned = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return parseMessageResult{Text: strings.TrimSpace(text), IsBotMentioned: mentioned}
|
||||
}
|
||||
return parseMessageResult{}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
c.handleMessage(evt)
|
||||
case "meta_event":
|
||||
c.handleMetaEvent(raw)
|
||||
case "notice":
|
||||
logger.DebugCF("onebot", "Notice event received", map[string]interface{}{
|
||||
"sub_type": raw.SubType,
|
||||
})
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent, error) {
|
||||
userID, err := parseJSONInt64(raw.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse user_id: %w (raw: %s)", err, string(raw.UserID))
|
||||
}
|
||||
|
||||
groupID, _ := parseJSONInt64(raw.GroupID)
|
||||
selfID, _ := parseJSONInt64(raw.SelfID)
|
||||
ts, _ := parseJSONInt64(raw.Time)
|
||||
messageID := parseJSONString(raw.MessageID)
|
||||
|
||||
parsed := parseMessageContentEx(raw.Message, selfID)
|
||||
isBotMentioned := parsed.IsBotMentioned
|
||||
|
||||
content := raw.RawMessage
|
||||
if content == "" {
|
||||
content = parsed.Text
|
||||
} else if selfID > 0 {
|
||||
cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID)
|
||||
if strings.Contains(content, cqAt) {
|
||||
isBotMentioned = true
|
||||
content = strings.ReplaceAll(content, cqAt, "")
|
||||
content = strings.TrimSpace(content)
|
||||
}
|
||||
}
|
||||
|
||||
var sender oneBotSender
|
||||
if len(raw.Sender) > 0 {
|
||||
if err := json.Unmarshal(raw.Sender, &sender); err != nil {
|
||||
logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
"sender": string(raw.Sender),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) handleMessage(evt *oneBotEvent) {
|
||||
if c.isDuplicate(evt.MessageID) {
|
||||
logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{
|
||||
"message_id": evt.MessageID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
content := evt.Content
|
||||
if content == "" {
|
||||
logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{
|
||||
"message_id": evt.MessageID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
senderID := strconv.FormatInt(evt.UserID, 10)
|
||||
var chatID string
|
||||
|
||||
metadata := map[string]string{
|
||||
"message_id": evt.MessageID,
|
||||
}
|
||||
|
||||
switch evt.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),
|
||||
})
|
||||
|
||||
case "group":
|
||||
groupIDStr := strconv.FormatInt(evt.GroupID, 10)
|
||||
chatID = "group:" + groupIDStr
|
||||
metadata["group_id"] = groupIDStr
|
||||
|
||||
senderUserID, _ := parseJSONInt64(evt.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
|
||||
}
|
||||
|
||||
triggered, strippedContent := c.checkGroupTrigger(content, evt.IsBotMentioned)
|
||||
if !triggered {
|
||||
logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{
|
||||
"sender": senderID,
|
||||
"group": groupIDStr,
|
||||
"is_mentioned": evt.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,
|
||||
})
|
||||
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),
|
||||
})
|
||||
|
||||
c.HandleMessage(senderID, chatID, content, []string{}, metadata)
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) isDuplicate(messageID string) bool {
|
||||
if messageID == "" || messageID == "0" {
|
||||
return false
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if _, exists := c.dedup[messageID]; exists {
|
||||
return true
|
||||
}
|
||||
|
||||
if old := c.dedupRing[c.dedupIdx]; old != "" {
|
||||
delete(c.dedup, old)
|
||||
}
|
||||
c.dedupRing[c.dedupIdx] = messageID
|
||||
c.dedup[messageID] = struct{}{}
|
||||
c.dedupIdx = (c.dedupIdx + 1) % len(c.dedupRing)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= n {
|
||||
return s
|
||||
}
|
||||
return string(runes[:n]) + "..."
|
||||
}
|
||||
|
||||
func (c *OneBotChannel) checkGroupTrigger(content string, isBotMentioned bool) (triggered bool, strippedContent string) {
|
||||
if isBotMentioned {
|
||||
return true, strings.TrimSpace(content)
|
||||
}
|
||||
|
||||
for _, prefix := range c.config.GroupTriggerPrefix {
|
||||
if prefix == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(content, prefix) {
|
||||
return true, strings.TrimSpace(strings.TrimPrefix(content, prefix))
|
||||
}
|
||||
}
|
||||
|
||||
return false, content
|
||||
}
|
||||
|
|
@ -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{}{
|
||||
|
|
@ -302,6 +314,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
|
||||
|
|
@ -330,12 +349,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)
|
||||
|
|
@ -351,6 +380,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
|
||||
if !c.IsAllowed(senderID) {
|
||||
logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]interface{}{
|
||||
|
|
@ -373,6 +409,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{}{
|
||||
|
|
|
|||
|
|
@ -3,13 +3,18 @@ package channels
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"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"
|
||||
|
|
@ -22,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
|
||||
|
|
@ -39,16 +45,32 @@ func (c *thinkingCancel) Cancel() {
|
|||
}
|
||||
}
|
||||
|
||||
func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) {
|
||||
bot, err := telego.NewBot(cfg.Token)
|
||||
func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
|
||||
var opts []telego.BotOption
|
||||
telegramCfg := cfg.Channels.Telegram
|
||||
|
||||
if telegramCfg.Proxy != "" {
|
||||
proxyURL, parseErr := url.Parse(telegramCfg.Proxy)
|
||||
if parseErr != nil {
|
||||
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),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
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),
|
||||
|
|
@ -72,31 +94,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)
|
||||
|
|
@ -150,15 +186,14 @@ 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")
|
||||
}
|
||||
|
||||
senderID := fmt.Sprintf("%d", user.ID)
|
||||
|
|
@ -171,7 +206,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{
|
||||
"user_id": senderID,
|
||||
})
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
chatID := message.Chat.ID
|
||||
|
|
@ -204,7 +239,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 != "" {
|
||||
|
|
@ -213,7 +248,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
content += fmt.Sprintf("[image: photo]")
|
||||
content += "[image: photo]"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -234,7 +269,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{}{
|
||||
|
|
@ -242,7 +277,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||
})
|
||||
}
|
||||
} else {
|
||||
transcribedText = fmt.Sprintf("[voice]")
|
||||
transcribedText = "[voice]"
|
||||
}
|
||||
|
||||
if content != "" {
|
||||
|
|
@ -260,7 +295,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
content += fmt.Sprintf("[audio]")
|
||||
content += "[audio]"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -272,7 +307,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
content += fmt.Sprintf("[file]")
|
||||
content += "[file]"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -302,37 +337,21 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||
}
|
||||
}
|
||||
|
||||
// Create new context for thinking animation with timeout
|
||||
thinkCtx, thinkCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
// Create cancel function for thinking state
|
||||
_, thinkCancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel})
|
||||
|
||||
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭"))
|
||||
if err == nil {
|
||||
pID := pMsg.MessageID
|
||||
c.placeholders.Store(chatIDStr, pID)
|
||||
}
|
||||
|
||||
go func(cid int64, mid int) {
|
||||
dots := []string{".", "..", "..."}
|
||||
emotes := []string{"💭", "🤔", "☁️"}
|
||||
i := 0
|
||||
ticker := time.NewTicker(2000 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-thinkCtx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
i++
|
||||
text := fmt.Sprintf("Thinking%s %s", dots[i%len(dots)], emotes[i%len(emotes)])
|
||||
_, editErr := c.bot.EditMessageText(thinkCtx, tu.EditMessageText(tu.ID(chatID), mid, text))
|
||||
if editErr != nil {
|
||||
logger.DebugCF("telegram", "Failed to edit thinking message", map[string]interface{}{
|
||||
"error": editErr.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}(chatID, 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{
|
||||
|
|
@ -341,9 +360,12 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
|
|||
"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 {
|
||||
|
|
@ -454,8 +476,11 @@ func extractCodeBlocks(text string) codeBlockMatch {
|
|||
codes = append(codes, match[1])
|
||||
}
|
||||
|
||||
i := 0
|
||||
text = re.ReplaceAllStringFunc(text, func(m string) string {
|
||||
return fmt.Sprintf("\x00CB%d\x00", len(codes)-1)
|
||||
placeholder := fmt.Sprintf("\x00CB%d\x00", i)
|
||||
i++
|
||||
return placeholder
|
||||
})
|
||||
|
||||
return codeBlockMatch{text: text, codes: codes}
|
||||
|
|
@ -475,8 +500,11 @@ func extractInlineCodes(text string) inlineCodeMatch {
|
|||
codes = append(codes, match[1])
|
||||
}
|
||||
|
||||
i := 0
|
||||
text = re.ReplaceAllStringFunc(text, func(m string) string {
|
||||
return fmt.Sprintf("\x00IC%d\x00", len(codes)-1)
|
||||
placeholder := fmt.Sprintf("\x00IC%d\x00", i)
|
||||
i++
|
||||
return placeholder
|
||||
})
|
||||
|
||||
return inlineCodeMatch{text: text, codes: codes}
|
||||
|
|
|
|||
153
pkg/channels/telegram_commands.go
Normal file
153
pkg/channels/telegram_commands.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package config
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
|
@ -10,22 +11,142 @@ import (
|
|||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
// FlexibleStringSlice is a []string that also accepts JSON numbers,
|
||||
// so allow_from can contain both "123" and 123.
|
||||
type FlexibleStringSlice []string
|
||||
|
||||
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
|
||||
// Try []string first
|
||||
var ss []string
|
||||
if err := json.Unmarshal(data, &ss); err == nil {
|
||||
*f = ss
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try []interface{} to handle mixed types
|
||||
var raw []interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(raw))
|
||||
for _, v := range raw {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
result = append(result, val)
|
||||
case float64:
|
||||
result = append(result, fmt.Sprintf("%.0f", val))
|
||||
default:
|
||||
result = append(result, fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
*f = result
|
||||
return nil
|
||||
}
|
||||
|
||||
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"`
|
||||
Gateway GatewayConfig `json:"gateway"`
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||
Devices DevicesConfig `json:"devices"`
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
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"`
|
||||
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" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
||||
|
|
@ -40,18 +161,21 @@ type ChannelsConfig struct {
|
|||
QQ QQConfig `json:"qq"`
|
||||
DingTalk DingTalkConfig `json:"dingtalk"`
|
||||
Slack SlackConfig `json:"slack"`
|
||||
LINE LINEConfig `json:"line"`
|
||||
OneBot OneBotConfig `json:"onebot"`
|
||||
}
|
||||
|
||||
type WhatsAppConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
|
||||
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
|
||||
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type TelegramConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
|
||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
|
||||
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
||||
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type FeishuConfig struct {
|
||||
|
|
@ -60,57 +184,99 @@ type FeishuConfig struct {
|
|||
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
|
||||
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
|
||||
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
|
||||
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type DiscordConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
|
||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
|
||||
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type MaixCamConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
|
||||
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
|
||||
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
|
||||
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type QQConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
|
||||
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
|
||||
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
|
||||
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type DingTalkConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
|
||||
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
|
||||
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
|
||||
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type SlackConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
|
||||
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
|
||||
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
|
||||
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type LINEConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
|
||||
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
|
||||
ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
|
||||
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
|
||||
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
|
||||
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type OneBotConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
|
||||
WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
|
||||
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
|
||||
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
|
||||
GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
|
||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
||||
}
|
||||
|
||||
type HeartbeatConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||
}
|
||||
|
||||
type DevicesConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
|
||||
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
|
||||
}
|
||||
|
||||
type ProvidersConfig struct {
|
||||
Anthropic ProviderConfig `json:"anthropic"`
|
||||
OpenAI ProviderConfig `json:"openai"`
|
||||
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"`
|
||||
GitHubCopilot ProviderConfig `json:"github_copilot"`
|
||||
}
|
||||
|
||||
type ProviderConfig struct {
|
||||
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
|
||||
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
|
||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
|
||||
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type GatewayConfig struct {
|
||||
|
|
@ -118,17 +284,42 @@ type GatewayConfig struct {
|
|||
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
|
||||
}
|
||||
|
||||
type WebSearchConfig struct {
|
||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_SEARCH_API_KEY"`
|
||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARCH_MAX_RESULTS"`
|
||||
type BraveConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
|
||||
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
|
||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
|
||||
}
|
||||
|
||||
type DuckDuckGoConfig struct {
|
||||
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
|
||||
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 {
|
||||
Search WebSearchConfig `json:"search"`
|
||||
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 {
|
||||
Web WebToolsConfig `json:"web"`
|
||||
Cron CronToolsConfig `json:"cron"`
|
||||
Exec ExecConfig `json:"exec"`
|
||||
}
|
||||
|
||||
func DefaultConfig() *Config {
|
||||
|
|
@ -136,6 +327,8 @@ func DefaultConfig() *Config {
|
|||
Agents: AgentsConfig{
|
||||
Defaults: AgentDefaults{
|
||||
Workspace: "~/.picoclaw/workspace",
|
||||
RestrictToWorkspace: true,
|
||||
Provider: "",
|
||||
Model: "openrouter/auto",
|
||||
MaxTokens: 8192,
|
||||
Temperature: 0.7,
|
||||
|
|
@ -146,12 +339,12 @@ func DefaultConfig() *Config {
|
|||
WhatsApp: WhatsAppConfig{
|
||||
Enabled: false,
|
||||
BridgeURL: "ws://localhost:3001",
|
||||
AllowFrom: []string{},
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Telegram: TelegramConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
AllowFrom: []string{},
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Feishu: FeishuConfig{
|
||||
Enabled: false,
|
||||
|
|
@ -159,46 +352,66 @@ func DefaultConfig() *Config {
|
|||
AppSecret: "",
|
||||
EncryptKey: "",
|
||||
VerificationToken: "",
|
||||
AllowFrom: []string{},
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Discord: DiscordConfig{
|
||||
Enabled: false,
|
||||
Token: "",
|
||||
AllowFrom: []string{},
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
MaixCam: MaixCamConfig{
|
||||
Enabled: false,
|
||||
Host: "0.0.0.0",
|
||||
Port: 18790,
|
||||
AllowFrom: []string{},
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
QQ: QQConfig{
|
||||
Enabled: false,
|
||||
AppID: "",
|
||||
AppSecret: "",
|
||||
AllowFrom: []string{},
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
DingTalk: DingTalkConfig{
|
||||
Enabled: false,
|
||||
ClientID: "",
|
||||
ClientSecret: "",
|
||||
AllowFrom: []string{},
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
Slack: SlackConfig{
|
||||
Enabled: false,
|
||||
BotToken: "",
|
||||
AppToken: "",
|
||||
AllowFrom: []string{},
|
||||
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{
|
||||
Anthropic: ProviderConfig{},
|
||||
OpenAI: ProviderConfig{},
|
||||
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
||||
OpenRouter: ProviderConfig{},
|
||||
Groq: ProviderConfig{},
|
||||
Zhipu: ProviderConfig{},
|
||||
VLLM: ProviderConfig{},
|
||||
Gemini: ProviderConfig{},
|
||||
Nvidia: ProviderConfig{},
|
||||
Moonshot: ProviderConfig{},
|
||||
ShengSuanYun: ProviderConfig{},
|
||||
},
|
||||
Gateway: GatewayConfig{
|
||||
Host: "0.0.0.0",
|
||||
|
|
@ -206,11 +419,35 @@ func DefaultConfig() *Config {
|
|||
},
|
||||
Tools: ToolsConfig{
|
||||
Web: WebToolsConfig{
|
||||
Search: WebSearchConfig{
|
||||
Brave: BraveConfig{
|
||||
Enabled: false,
|
||||
APIKey: "",
|
||||
MaxResults: 5,
|
||||
},
|
||||
DuckDuckGo: DuckDuckGoConfig{
|
||||
Enabled: true,
|
||||
MaxResults: 5,
|
||||
},
|
||||
Perplexity: PerplexityConfig{
|
||||
Enabled: false,
|
||||
APIKey: "",
|
||||
MaxResults: 5,
|
||||
},
|
||||
},
|
||||
Cron: CronToolsConfig{
|
||||
ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations
|
||||
},
|
||||
Exec: ExecConfig{
|
||||
EnableDenyPatterns: true,
|
||||
},
|
||||
},
|
||||
Heartbeat: HeartbeatConfig{
|
||||
Enabled: true,
|
||||
Interval: 30, // default 30 minutes
|
||||
},
|
||||
Devices: DevicesConfig{
|
||||
Enabled: false,
|
||||
MonitorUSB: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -286,6 +523,9 @@ func (c *Config) GetAPIKey() string {
|
|||
if c.Providers.VLLM.APIKey != "" {
|
||||
return c.Providers.VLLM.APIKey
|
||||
}
|
||||
if c.Providers.ShengSuanYun.APIKey != "" {
|
||||
return c.Providers.ShengSuanYun.APIKey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
@ -307,6 +547,32 @@ func (c *Config) GetAPIBase() string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// ModelConfig holds primary model and fallback list.
|
||||
type ModelConfig struct {
|
||||
Primary string
|
||||
Fallbacks []string
|
||||
}
|
||||
|
||||
// GetModelConfig returns the text model configuration with fallbacks.
|
||||
func (c *Config) GetModelConfig() ModelConfig {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return ModelConfig{
|
||||
Primary: c.Agents.Defaults.Model,
|
||||
Fallbacks: c.Agents.Defaults.ModelFallbacks,
|
||||
}
|
||||
}
|
||||
|
||||
// GetImageModelConfig returns the image model configuration with fallbacks.
|
||||
func (c *Config) GetImageModelConfig() ModelConfig {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return ModelConfig{
|
||||
Primary: c.Agents.Defaults.ImageModel,
|
||||
Fallbacks: c.Agents.Defaults.ImageModelFallbacks,
|
||||
}
|
||||
}
|
||||
|
||||
func expandHome(path string) string {
|
||||
if path == "" {
|
||||
return path
|
||||
|
|
|
|||
|
|
@ -1,44 +1,403 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultConfigUsesOpenRouterAutoModel(t *testing.T) {
|
||||
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()
|
||||
|
||||
if cfg.Agents.Defaults.Model != "openrouter/auto" {
|
||||
t.Fatalf("default model = %q, want %q", cfg.Agents.Defaults.Model, "openrouter/auto")
|
||||
if !cfg.Heartbeat.Enabled {
|
||||
t.Error("Heartbeat should be enabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveConfigWritesPrivatePermissions(t *testing.T) {
|
||||
// TestDefaultConfig_WorkspacePath verifies workspace path is correctly set
|
||||
func TestDefaultConfig_WorkspacePath(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Agents.Defaults.Workspace == "" {
|
||||
t.Error("Workspace should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_Model verifies model is set
|
||||
func TestDefaultConfig_Model(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Agents.Defaults.Model == "" {
|
||||
t.Error("Model should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_MaxTokens verifies max tokens has default value
|
||||
func TestDefaultConfig_MaxTokens(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Agents.Defaults.MaxTokens == 0 {
|
||||
t.Error("MaxTokens should not be zero")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_MaxToolIterations verifies max tool iterations has default value
|
||||
func TestDefaultConfig_MaxToolIterations(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Agents.Defaults.MaxToolIterations == 0 {
|
||||
t.Error("MaxToolIterations should not be zero")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_Temperature verifies temperature has default value
|
||||
func TestDefaultConfig_Temperature(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Agents.Defaults.Temperature == 0 {
|
||||
t.Error("Temperature should not be zero")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_Gateway verifies gateway defaults
|
||||
func TestDefaultConfig_Gateway(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Gateway.Host != "0.0.0.0" {
|
||||
t.Error("Gateway host should have default value")
|
||||
}
|
||||
if cfg.Gateway.Port == 0 {
|
||||
t.Error("Gateway port should have default value")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_Providers verifies provider structure
|
||||
func TestDefaultConfig_Providers(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Providers.Anthropic.APIKey != "" {
|
||||
t.Error("Anthropic API key should be empty by default")
|
||||
}
|
||||
if cfg.Providers.OpenAI.APIKey != "" {
|
||||
t.Error("OpenAI API key should be empty by default")
|
||||
}
|
||||
if cfg.Providers.OpenRouter.APIKey != "" {
|
||||
t.Error("OpenRouter API key should be empty by default")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_Channels verifies channels are disabled by default
|
||||
func TestDefaultConfig_Channels(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Channels.Telegram.Enabled {
|
||||
t.Error("Telegram should be disabled by default")
|
||||
}
|
||||
if cfg.Channels.Discord.Enabled {
|
||||
t.Error("Discord should be disabled by default")
|
||||
}
|
||||
if cfg.Channels.Slack.Enabled {
|
||||
t.Error("Slack should be disabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_WebTools verifies web tools config
|
||||
func TestDefaultConfig_WebTools(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
// Verify web tools defaults
|
||||
if cfg.Tools.Web.Brave.MaxResults != 5 {
|
||||
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
|
||||
}
|
||||
if cfg.Tools.Web.Brave.APIKey != "" {
|
||||
t.Error("Brave API key should be empty by default")
|
||||
}
|
||||
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
|
||||
t.Error("Expected DuckDuckGo MaxResults 5, got ", cfg.Tools.Web.DuckDuckGo.MaxResults)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveConfig_FilePermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("file permission bits are not reliable on windows")
|
||||
t.Skip("file permission bits are not enforced on Windows")
|
||||
}
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte("{}"), 0644); err != nil {
|
||||
t.Fatalf("failed to create seed config: %v", err)
|
||||
}
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "config.json")
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.Providers.OpenRouter.APIKey = "secret"
|
||||
|
||||
if err := SaveConfig(path, cfg); err != nil {
|
||||
t.Fatalf("SaveConfig failed: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat config: %v", err)
|
||||
t.Fatalf("Stat failed: %v", err)
|
||||
}
|
||||
|
||||
if got := info.Mode().Perm(); got != 0600 {
|
||||
t.Fatalf("config perms = %o, want 600", got)
|
||||
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()
|
||||
|
||||
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.MaxTokens == 0 {
|
||||
t.Error("MaxTokens should not be zero")
|
||||
}
|
||||
if cfg.Agents.Defaults.MaxToolIterations == 0 {
|
||||
t.Error("MaxToolIterations should not be zero")
|
||||
}
|
||||
if cfg.Gateway.Host != "0.0.0.0" {
|
||||
t.Error("Gateway host should have default value")
|
||||
}
|
||||
if cfg.Gateway.Port == 0 {
|
||||
t.Error("Gateway port should have default value")
|
||||
}
|
||||
if !cfg.Heartbeat.Enabled {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfigUsesOpenRouterAutoModel(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
||||
if cfg.Agents.Defaults.Model != "openrouter/auto" {
|
||||
t.Fatalf("default model = %q, want %q", cfg.Agents.Defaults.Model, "openrouter/auto")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +408,7 @@ func TestLoadConfigNormalizesLegacyGLMModelForOpenRouter(t *testing.T) {
|
|||
"providers": { "openrouter": { "api_key": "sk-or-test" } }
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config fixture: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -73,7 +432,7 @@ func TestLoadConfigKeepsLegacyGLMModelWhenZhipuConfigured(t *testing.T) {
|
|||
}
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config fixture: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +452,7 @@ func TestLoadConfigKeepsLegacyGLMModelWhenNoProviderKeysConfigured(t *testing.T)
|
|||
"agents": { "defaults": { "model": "glm-4.7" } }
|
||||
}`
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("failed to write config fixture: %v", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
15
pkg/constants/channels.go
Normal file
15
pkg/constants/channels.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Package constants provides shared constants across the codebase.
|
||||
package constants
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
// IsInternalChannel returns true if the channel is an internal channel.
|
||||
func IsInternalChannel(channel string) bool {
|
||||
return InternalChannels[channel]
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ type CronSchedule struct {
|
|||
type CronPayload struct {
|
||||
Kind string `json:"kind"`
|
||||
Message string `json:"message"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Deliver bool `json:"deliver"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
|
|
@ -71,7 +72,6 @@ func NewCronService(storePath string, onJob JobHandler) *CronService {
|
|||
cs := &CronService{
|
||||
storePath: storePath,
|
||||
onJob: onJob,
|
||||
stopChan: make(chan struct{}),
|
||||
gronx: gronx.New(),
|
||||
}
|
||||
// Initialize and load store on creation
|
||||
|
|
@ -96,8 +96,9 @@ func (cs *CronService) Start() error {
|
|||
return fmt.Errorf("failed to save store: %w", err)
|
||||
}
|
||||
|
||||
cs.stopChan = make(chan struct{})
|
||||
cs.running = true
|
||||
go cs.runLoop()
|
||||
go cs.runLoop(cs.stopChan)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -111,16 +112,19 @@ func (cs *CronService) Stop() {
|
|||
}
|
||||
|
||||
cs.running = false
|
||||
if cs.stopChan != nil {
|
||||
close(cs.stopChan)
|
||||
cs.stopChan = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *CronService) runLoop() {
|
||||
func (cs *CronService) runLoop(stopChan chan struct{}) {
|
||||
ticker := time.NewTicker(1 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-cs.stopChan:
|
||||
case <-stopChan:
|
||||
return
|
||||
case <-ticker.C:
|
||||
cs.checkJobs()
|
||||
|
|
@ -137,27 +141,23 @@ func (cs *CronService) checkJobs() {
|
|||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
var dueJobs []*CronJob
|
||||
var dueJobIDs []string
|
||||
|
||||
// Collect jobs that are due (we need to copy them to execute outside lock)
|
||||
for i := range cs.store.Jobs {
|
||||
job := &cs.store.Jobs[i]
|
||||
if job.Enabled && job.State.NextRunAtMS != nil && *job.State.NextRunAtMS <= now {
|
||||
// Create a shallow copy of the job for execution
|
||||
jobCopy := *job
|
||||
dueJobs = append(dueJobs, &jobCopy)
|
||||
dueJobIDs = append(dueJobIDs, job.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Update next run times for due jobs immediately (before executing)
|
||||
// Use map for O(n) lookup instead of O(n²) nested loop
|
||||
dueMap := make(map[string]bool, len(dueJobs))
|
||||
for _, job := range dueJobs {
|
||||
dueMap[job.ID] = true
|
||||
// Reset next run for due jobs before unlocking to avoid duplicate execution.
|
||||
dueMap := make(map[string]bool, len(dueJobIDs))
|
||||
for _, jobID := range dueJobIDs {
|
||||
dueMap[jobID] = true
|
||||
}
|
||||
for i := range cs.store.Jobs {
|
||||
if dueMap[cs.store.Jobs[i].ID] {
|
||||
// Reset NextRunAtMS temporarily so we don't re-execute
|
||||
cs.store.Jobs[i].State.NextRunAtMS = nil
|
||||
}
|
||||
}
|
||||
|
|
@ -168,52 +168,74 @@ func (cs *CronService) checkJobs() {
|
|||
|
||||
cs.mu.Unlock()
|
||||
|
||||
// Execute jobs outside the lock
|
||||
for _, job := range dueJobs {
|
||||
cs.executeJob(job)
|
||||
// Execute jobs outside lock.
|
||||
for _, jobID := range dueJobIDs {
|
||||
cs.executeJobByID(jobID)
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *CronService) executeJob(job *CronJob) {
|
||||
func (cs *CronService) executeJobByID(jobID string) {
|
||||
startTime := time.Now().UnixMilli()
|
||||
|
||||
cs.mu.RLock()
|
||||
var callbackJob *CronJob
|
||||
for i := range cs.store.Jobs {
|
||||
job := &cs.store.Jobs[i]
|
||||
if job.ID == jobID {
|
||||
jobCopy := *job
|
||||
callbackJob = &jobCopy
|
||||
break
|
||||
}
|
||||
}
|
||||
cs.mu.RUnlock()
|
||||
|
||||
if callbackJob == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
if cs.onJob != nil {
|
||||
_, err = cs.onJob(job)
|
||||
_, err = cs.onJob(callbackJob)
|
||||
}
|
||||
|
||||
// Now acquire lock to update state
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
// Find the job in store and update it
|
||||
var job *CronJob
|
||||
for i := range cs.store.Jobs {
|
||||
if cs.store.Jobs[i].ID == job.ID {
|
||||
cs.store.Jobs[i].State.LastRunAtMS = &startTime
|
||||
cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli()
|
||||
if cs.store.Jobs[i].ID == jobID {
|
||||
job = &cs.store.Jobs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if job == nil {
|
||||
log.Printf("[cron] job %s disappeared before state update", jobID)
|
||||
return
|
||||
}
|
||||
|
||||
job.State.LastRunAtMS = &startTime
|
||||
job.UpdatedAtMS = time.Now().UnixMilli()
|
||||
|
||||
if err != nil {
|
||||
cs.store.Jobs[i].State.LastStatus = "error"
|
||||
cs.store.Jobs[i].State.LastError = err.Error()
|
||||
job.State.LastStatus = "error"
|
||||
job.State.LastError = err.Error()
|
||||
} else {
|
||||
cs.store.Jobs[i].State.LastStatus = "ok"
|
||||
cs.store.Jobs[i].State.LastError = ""
|
||||
job.State.LastStatus = "ok"
|
||||
job.State.LastError = ""
|
||||
}
|
||||
|
||||
// Compute next run time
|
||||
if cs.store.Jobs[i].Schedule.Kind == "at" {
|
||||
if cs.store.Jobs[i].DeleteAfterRun {
|
||||
if job.Schedule.Kind == "at" {
|
||||
if job.DeleteAfterRun {
|
||||
cs.removeJobUnsafe(job.ID)
|
||||
} else {
|
||||
cs.store.Jobs[i].Enabled = false
|
||||
cs.store.Jobs[i].State.NextRunAtMS = nil
|
||||
job.Enabled = false
|
||||
job.State.NextRunAtMS = nil
|
||||
}
|
||||
} else {
|
||||
nextRun := cs.computeNextRun(&cs.store.Jobs[i].Schedule, time.Now().UnixMilli())
|
||||
cs.store.Jobs[i].State.NextRunAtMS = nextRun
|
||||
}
|
||||
break
|
||||
}
|
||||
nextRun := cs.computeNextRun(&job.Schedule, time.Now().UnixMilli())
|
||||
job.State.NextRunAtMS = nextRun
|
||||
}
|
||||
|
||||
if err := cs.saveStoreUnsafe(); err != nil {
|
||||
|
|
@ -359,6 +381,20 @@ func (cs *CronService) AddJob(name string, schedule CronSchedule, message string
|
|||
return &job, nil
|
||||
}
|
||||
|
||||
func (cs *CronService) UpdateJob(job *CronJob) error {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
||||
for i := range cs.store.Jobs {
|
||||
if cs.store.Jobs[i].ID == job.ID {
|
||||
cs.store.Jobs[i] = *job
|
||||
cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli()
|
||||
return cs.saveStoreUnsafe()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("job not found")
|
||||
}
|
||||
|
||||
func (cs *CronService) RemoveJob(jobID string) bool {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
|
|
|
|||
|
|
@ -7,27 +7,47 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
func TestAddJobWritesPrivatePermissions(t *testing.T) {
|
||||
func TestSaveStore_FilePermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("file permission bits are not reliable on 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 TestSaveStoreFixesExistingFilePermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("file permission bits are not enforced on Windows")
|
||||
}
|
||||
|
||||
storePath := filepath.Join(t.TempDir(), "cron", "jobs.json")
|
||||
if err := os.MkdirAll(filepath.Dir(storePath), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(storePath), 0o755); err != nil {
|
||||
t.Fatalf("failed to create store dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(storePath, []byte(`{"version":1,"jobs":[]}`), 0644); err != nil {
|
||||
if err := os.WriteFile(storePath, []byte(`{"version":1,"jobs":[]}`), 0o644); err != nil {
|
||||
t.Fatalf("failed to create seed cron store: %v", err)
|
||||
}
|
||||
|
||||
cs := NewCronService(storePath, nil)
|
||||
everyMS := int64(60000)
|
||||
schedule := CronSchedule{
|
||||
Kind: "every",
|
||||
EveryMS: &everyMS,
|
||||
}
|
||||
|
||||
if _, err := cs.AddJob("perm-test", schedule, "hello", true, "cli", "direct"); err != nil {
|
||||
if _, err := cs.AddJob("perm-test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", true, "cli", "direct"); err != nil {
|
||||
t.Fatalf("AddJob failed: %v", err)
|
||||
}
|
||||
|
||||
|
|
@ -36,7 +56,11 @@ func TestAddJobWritesPrivatePermissions(t *testing.T) {
|
|||
t.Fatalf("stat cron store: %v", err)
|
||||
}
|
||||
|
||||
if got := info.Mode().Perm(); got != 0600 {
|
||||
if got := info.Mode().Perm(); got != 0o600 {
|
||||
t.Fatalf("cron store perms = %o, want 600", got)
|
||||
}
|
||||
}
|
||||
|
||||
func int64Ptr(v int64) *int64 {
|
||||
return &v
|
||||
}
|
||||
|
|
|
|||
57
pkg/devices/events/events.go
Normal file
57
pkg/devices/events/events.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
package events
|
||||
|
||||
import "context"
|
||||
|
||||
type EventSource interface {
|
||||
Kind() Kind
|
||||
Start(ctx context.Context) (<-chan *DeviceEvent, error)
|
||||
Stop() error
|
||||
}
|
||||
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionAdd Action = "add"
|
||||
ActionRemove Action = "remove"
|
||||
ActionChange Action = "change"
|
||||
)
|
||||
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindUSB Kind = "usb"
|
||||
KindBluetooth Kind = "bluetooth"
|
||||
KindPCI Kind = "pci"
|
||||
KindGeneric Kind = "generic"
|
||||
)
|
||||
|
||||
type DeviceEvent struct {
|
||||
Action Action
|
||||
Kind Kind
|
||||
DeviceID string // e.g. "1-2" for USB bus 1 dev 2
|
||||
Vendor string // Vendor name or ID
|
||||
Product string // Product name or ID
|
||||
Serial string // Serial number if available
|
||||
Capabilities string // Human-readable capability description
|
||||
Raw map[string]string // Raw properties for extensibility
|
||||
}
|
||||
|
||||
func (e *DeviceEvent) FormatMessage() string {
|
||||
actionEmoji := "🔌"
|
||||
actionText := "Connected"
|
||||
if e.Action == ActionRemove {
|
||||
actionEmoji = "🔌"
|
||||
actionText = "Disconnected"
|
||||
}
|
||||
|
||||
msg := actionEmoji + " Device " + actionText + "\n\n"
|
||||
msg += "Type: " + string(e.Kind) + "\n"
|
||||
msg += "Device: " + e.Vendor + " " + e.Product + "\n"
|
||||
if e.Capabilities != "" {
|
||||
msg += "Capabilities: " + e.Capabilities + "\n"
|
||||
}
|
||||
if e.Serial != "" {
|
||||
msg += "Serial: " + e.Serial + "\n"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
152
pkg/devices/service.go
Normal file
152
pkg/devices/service.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/constants"
|
||||
"github.com/sipeed/picoclaw/pkg/devices/events"
|
||||
"github.com/sipeed/picoclaw/pkg/devices/sources"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/state"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
bus *bus.MessageBus
|
||||
state *state.Manager
|
||||
sources []events.EventSource
|
||||
enabled bool
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
MonitorUSB bool // When true, monitor USB hotplug (Linux only)
|
||||
// Future: MonitorBluetooth, MonitorPCI, etc.
|
||||
}
|
||||
|
||||
func NewService(cfg Config, stateMgr *state.Manager) *Service {
|
||||
s := &Service{
|
||||
state: stateMgr,
|
||||
enabled: cfg.Enabled,
|
||||
sources: make([]EventSource, 0),
|
||||
}
|
||||
|
||||
if cfg.Enabled && cfg.MonitorUSB {
|
||||
s.sources = append(s.sources, sources.NewUSBMonitor())
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Service) SetBus(msgBus *bus.MessageBus) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.bus = msgBus
|
||||
}
|
||||
|
||||
func (s *Service) Start(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.enabled || len(s.sources) == 0 {
|
||||
logger.InfoC("devices", "Device event service disabled or no sources")
|
||||
return nil
|
||||
}
|
||||
|
||||
s.ctx, s.cancel = context.WithCancel(ctx)
|
||||
|
||||
for _, src := range s.sources {
|
||||
eventCh, err := src.Start(s.ctx)
|
||||
if err != nil {
|
||||
logger.ErrorCF("devices", "Failed to start source", map[string]interface{}{
|
||||
"kind": src.Kind(),
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
go s.handleEvents(src.Kind(), eventCh)
|
||||
logger.InfoCF("devices", "Device source started", map[string]interface{}{
|
||||
"kind": src.Kind(),
|
||||
})
|
||||
}
|
||||
|
||||
logger.InfoC("devices", "Device event service started")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
s.cancel = nil
|
||||
}
|
||||
|
||||
for _, src := range s.sources {
|
||||
src.Stop()
|
||||
}
|
||||
|
||||
logger.InfoC("devices", "Device event service stopped")
|
||||
}
|
||||
|
||||
func (s *Service) handleEvents(kind events.Kind, eventCh <-chan *events.DeviceEvent) {
|
||||
for ev := range eventCh {
|
||||
if ev == nil {
|
||||
continue
|
||||
}
|
||||
s.sendNotification(ev)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) sendNotification(ev *events.DeviceEvent) {
|
||||
s.mu.RLock()
|
||||
msgBus := s.bus
|
||||
s.mu.RUnlock()
|
||||
|
||||
if msgBus == nil {
|
||||
return
|
||||
}
|
||||
|
||||
lastChannel := s.state.GetLastChannel()
|
||||
if lastChannel == "" {
|
||||
logger.DebugCF("devices", "No last channel, skipping notification", map[string]interface{}{
|
||||
"event": ev.FormatMessage(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
platform, userID := parseLastChannel(lastChannel)
|
||||
if platform == "" || userID == "" || constants.IsInternalChannel(platform) {
|
||||
return
|
||||
}
|
||||
|
||||
msg := ev.FormatMessage()
|
||||
msgBus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: platform,
|
||||
ChatID: userID,
|
||||
Content: msg,
|
||||
})
|
||||
|
||||
logger.InfoCF("devices", "Device notification sent", map[string]interface{}{
|
||||
"kind": ev.Kind,
|
||||
"action": ev.Action,
|
||||
"to": platform,
|
||||
})
|
||||
}
|
||||
|
||||
func parseLastChannel(lastChannel string) (platform, userID string) {
|
||||
if lastChannel == "" {
|
||||
return "", ""
|
||||
}
|
||||
parts := strings.SplitN(lastChannel, ":", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", ""
|
||||
}
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
5
pkg/devices/source.go
Normal file
5
pkg/devices/source.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package devices
|
||||
|
||||
import "github.com/sipeed/picoclaw/pkg/devices/events"
|
||||
|
||||
type EventSource = events.EventSource
|
||||
198
pkg/devices/sources/usb_linux.go
Normal file
198
pkg/devices/sources/usb_linux.go
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
//go:build linux
|
||||
|
||||
package sources
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/devices/events"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
)
|
||||
|
||||
var usbClassToCapability = map[string]string{
|
||||
"00": "Interface Definition (by interface)",
|
||||
"01": "Audio",
|
||||
"02": "CDC Communication (Network Card/Modem)",
|
||||
"03": "HID (Keyboard/Mouse/Gamepad)",
|
||||
"05": "Physical Interface",
|
||||
"06": "Image (Scanner/Camera)",
|
||||
"07": "Printer",
|
||||
"08": "Mass Storage (USB Flash Drive/Hard Disk)",
|
||||
"09": "USB Hub",
|
||||
"0a": "CDC Data",
|
||||
"0b": "Smart Card",
|
||||
"0e": "Video (Camera)",
|
||||
"dc": "Diagnostic Device",
|
||||
"e0": "Wireless Controller (Bluetooth)",
|
||||
"ef": "Miscellaneous",
|
||||
"fe": "Application Specific",
|
||||
"ff": "Vendor Specific",
|
||||
}
|
||||
|
||||
type USBMonitor struct {
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewUSBMonitor() *USBMonitor {
|
||||
return &USBMonitor{}
|
||||
}
|
||||
|
||||
func (m *USBMonitor) Kind() events.Kind {
|
||||
return events.KindUSB
|
||||
}
|
||||
|
||||
func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// udevadm monitor outputs: UDEV/KERNEL [timestamp] action devpath (subsystem)
|
||||
// Followed by KEY=value lines, empty line separates events
|
||||
// Use -s/--subsystem-match (eudev) or --udev-subsystem-match (systemd udev)
|
||||
cmd := exec.CommandContext(ctx, "udevadm", "monitor", "--property", "--subsystem-match=usb")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("udevadm stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("udevadm start: %w (is udevadm installed?)", err)
|
||||
}
|
||||
|
||||
m.cmd = cmd
|
||||
eventCh := make(chan *events.DeviceEvent, 16)
|
||||
|
||||
go func() {
|
||||
defer close(eventCh)
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
var props map[string]string
|
||||
var action string
|
||||
isUdev := false // Only UDEV events have complete info (ID_VENDOR, ID_MODEL); KERNEL events come first with less info
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
// End of event block - only process UDEV events (skip KERNEL to avoid duplicate/incomplete notifications)
|
||||
if isUdev && props != nil && (action == "add" || action == "remove") {
|
||||
if ev := parseUSBEvent(action, props); ev != nil {
|
||||
select {
|
||||
case eventCh <- ev:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
props = nil
|
||||
action = ""
|
||||
isUdev = false
|
||||
continue
|
||||
}
|
||||
|
||||
idx := strings.Index(line, "=")
|
||||
// First line of block: "UDEV [ts] action devpath" or "KERNEL[ts] action devpath" - no KEY=value
|
||||
if idx <= 0 {
|
||||
isUdev = strings.HasPrefix(strings.TrimSpace(line), "UDEV")
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse KEY=value
|
||||
key := line[:idx]
|
||||
val := line[idx+1:]
|
||||
if props == nil {
|
||||
props = make(map[string]string)
|
||||
}
|
||||
props[key] = val
|
||||
|
||||
if key == "ACTION" {
|
||||
action = val
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
logger.ErrorCF("devices", "udevadm scan error", map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
cmd.Wait()
|
||||
}()
|
||||
|
||||
return eventCh, nil
|
||||
}
|
||||
|
||||
func (m *USBMonitor) Stop() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.cmd != nil && m.cmd.Process != nil {
|
||||
m.cmd.Process.Kill()
|
||||
m.cmd = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseUSBEvent(action string, props map[string]string) *events.DeviceEvent {
|
||||
// Only care about add/remove for physical devices (not interfaces)
|
||||
subsystem := props["SUBSYSTEM"]
|
||||
if subsystem != "usb" {
|
||||
return nil
|
||||
}
|
||||
// Skip interface events - we want device-level only to avoid duplicates
|
||||
devType := props["DEVTYPE"]
|
||||
if devType == "usb_interface" {
|
||||
return nil
|
||||
}
|
||||
// Prefer usb_device, but accept if DEVTYPE not set (varies by udev version)
|
||||
if devType != "" && devType != "usb_device" {
|
||||
return nil
|
||||
}
|
||||
|
||||
ev := &events.DeviceEvent{
|
||||
Raw: props,
|
||||
}
|
||||
switch action {
|
||||
case "add":
|
||||
ev.Action = events.ActionAdd
|
||||
case "remove":
|
||||
ev.Action = events.ActionRemove
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
ev.Kind = events.KindUSB
|
||||
|
||||
ev.Vendor = props["ID_VENDOR"]
|
||||
if ev.Vendor == "" {
|
||||
ev.Vendor = props["ID_VENDOR_ID"]
|
||||
}
|
||||
if ev.Vendor == "" {
|
||||
ev.Vendor = "Unknown Vendor"
|
||||
}
|
||||
|
||||
ev.Product = props["ID_MODEL"]
|
||||
if ev.Product == "" {
|
||||
ev.Product = props["ID_MODEL_ID"]
|
||||
}
|
||||
if ev.Product == "" {
|
||||
ev.Product = "Unknown Device"
|
||||
}
|
||||
|
||||
ev.Serial = props["ID_SERIAL_SHORT"]
|
||||
ev.DeviceID = props["DEVPATH"]
|
||||
if bus := props["BUSNUM"]; bus != "" {
|
||||
if dev := props["DEVNUM"]; dev != "" {
|
||||
ev.DeviceID = bus + ":" + dev
|
||||
}
|
||||
}
|
||||
|
||||
// Map USB class to capability
|
||||
if class := props["ID_USB_CLASS"]; class != "" {
|
||||
ev.Capabilities = usbClassToCapability[strings.ToLower(class)]
|
||||
}
|
||||
if ev.Capabilities == "" {
|
||||
ev.Capabilities = "USB Device"
|
||||
}
|
||||
|
||||
return ev
|
||||
}
|
||||
29
pkg/devices/sources/usb_stub.go
Normal file
29
pkg/devices/sources/usb_stub.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
//go:build !linux
|
||||
|
||||
package sources
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/devices/events"
|
||||
)
|
||||
|
||||
type USBMonitor struct{}
|
||||
|
||||
func NewUSBMonitor() *USBMonitor {
|
||||
return &USBMonitor{}
|
||||
}
|
||||
|
||||
func (m *USBMonitor) Kind() events.Kind {
|
||||
return events.KindUSB
|
||||
}
|
||||
|
||||
func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) {
|
||||
ch := make(chan *events.DeviceEvent)
|
||||
close(ch) // Immediately close, no events
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (m *USBMonitor) Stop() error {
|
||||
return nil
|
||||
}
|
||||
164
pkg/health/server.go
Normal file
164
pkg/health/server.go
Normal file
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -1,128 +1,359 @@
|
|||
// PicoClaw - Ultra-lightweight personal AI agent
|
||||
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
|
||||
// License: MIT
|
||||
//
|
||||
// Copyright (c) 2026 PicoClaw contributors
|
||||
|
||||
package heartbeat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/constants"
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/state"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
minIntervalMinutes = 5
|
||||
defaultIntervalMinutes = 30
|
||||
)
|
||||
|
||||
// HeartbeatHandler is the function type for handling heartbeat.
|
||||
// It returns a ToolResult that can indicate async operations.
|
||||
// channel and chatID are derived from the last active user channel.
|
||||
type HeartbeatHandler func(prompt, channel, chatID string) *tools.ToolResult
|
||||
|
||||
// HeartbeatService manages periodic heartbeat checks
|
||||
type HeartbeatService struct {
|
||||
workspace string
|
||||
onHeartbeat func(string) (string, error)
|
||||
bus *bus.MessageBus
|
||||
state *state.Manager
|
||||
handler HeartbeatHandler
|
||||
interval time.Duration
|
||||
enabled bool
|
||||
mu sync.RWMutex
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
func NewHeartbeatService(workspace string, onHeartbeat func(string) (string, error), intervalS int, enabled bool) *HeartbeatService {
|
||||
// NewHeartbeatService creates a new heartbeat service
|
||||
func NewHeartbeatService(workspace string, intervalMinutes int, enabled bool) *HeartbeatService {
|
||||
// Apply minimum interval
|
||||
if intervalMinutes < minIntervalMinutes && intervalMinutes != 0 {
|
||||
intervalMinutes = minIntervalMinutes
|
||||
}
|
||||
|
||||
if intervalMinutes == 0 {
|
||||
intervalMinutes = defaultIntervalMinutes
|
||||
}
|
||||
|
||||
return &HeartbeatService{
|
||||
workspace: workspace,
|
||||
onHeartbeat: onHeartbeat,
|
||||
interval: time.Duration(intervalS) * time.Second,
|
||||
interval: time.Duration(intervalMinutes) * time.Minute,
|
||||
enabled: enabled,
|
||||
stopChan: make(chan struct{}),
|
||||
state: state.NewManager(workspace),
|
||||
}
|
||||
}
|
||||
|
||||
// SetBus sets the message bus for delivering heartbeat results.
|
||||
func (hs *HeartbeatService) SetBus(msgBus *bus.MessageBus) {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
hs.bus = msgBus
|
||||
}
|
||||
|
||||
// SetHandler sets the heartbeat handler.
|
||||
func (hs *HeartbeatService) SetHandler(handler HeartbeatHandler) {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
hs.handler = handler
|
||||
}
|
||||
|
||||
// Start begins the heartbeat service
|
||||
func (hs *HeartbeatService) Start() error {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
|
||||
if hs.running() {
|
||||
if hs.stopChan != nil {
|
||||
logger.InfoC("heartbeat", "Heartbeat service already running")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !hs.enabled {
|
||||
return fmt.Errorf("heartbeat service is disabled")
|
||||
logger.InfoC("heartbeat", "Heartbeat service disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
go hs.runLoop()
|
||||
hs.stopChan = make(chan struct{})
|
||||
go hs.runLoop(hs.stopChan)
|
||||
|
||||
logger.InfoCF("heartbeat", "Heartbeat service started", map[string]any{
|
||||
"interval_minutes": hs.interval.Minutes(),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully stops the heartbeat service
|
||||
func (hs *HeartbeatService) Stop() {
|
||||
hs.mu.Lock()
|
||||
defer hs.mu.Unlock()
|
||||
|
||||
if !hs.running() {
|
||||
if hs.stopChan == nil {
|
||||
return
|
||||
}
|
||||
|
||||
logger.InfoC("heartbeat", "Stopping heartbeat service")
|
||||
close(hs.stopChan)
|
||||
hs.stopChan = nil
|
||||
}
|
||||
|
||||
func (hs *HeartbeatService) running() bool {
|
||||
select {
|
||||
case <-hs.stopChan:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
// IsRunning returns whether the service is running
|
||||
func (hs *HeartbeatService) IsRunning() bool {
|
||||
hs.mu.RLock()
|
||||
defer hs.mu.RUnlock()
|
||||
return hs.stopChan != nil
|
||||
}
|
||||
|
||||
func (hs *HeartbeatService) runLoop() {
|
||||
// runLoop runs the heartbeat ticker
|
||||
func (hs *HeartbeatService) runLoop(stopChan chan struct{}) {
|
||||
ticker := time.NewTicker(hs.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Run first heartbeat after initial delay
|
||||
time.AfterFunc(time.Second, func() {
|
||||
hs.executeHeartbeat()
|
||||
})
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-hs.stopChan:
|
||||
case <-stopChan:
|
||||
return
|
||||
case <-ticker.C:
|
||||
hs.checkHeartbeat()
|
||||
hs.executeHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (hs *HeartbeatService) checkHeartbeat() {
|
||||
// executeHeartbeat performs a single heartbeat check
|
||||
func (hs *HeartbeatService) executeHeartbeat() {
|
||||
hs.mu.RLock()
|
||||
if !hs.enabled || !hs.running() {
|
||||
enabled := hs.enabled
|
||||
handler := hs.handler
|
||||
if !hs.enabled || hs.stopChan == nil {
|
||||
hs.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
hs.mu.RUnlock()
|
||||
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
|
||||
logger.DebugC("heartbeat", "Executing heartbeat")
|
||||
|
||||
prompt := hs.buildPrompt()
|
||||
|
||||
if hs.onHeartbeat != nil {
|
||||
_, err := hs.onHeartbeat(prompt)
|
||||
if err != nil {
|
||||
hs.log(fmt.Sprintf("Heartbeat error: %v", err))
|
||||
}
|
||||
}
|
||||
if prompt == "" {
|
||||
logger.InfoC("heartbeat", "No heartbeat prompt (HEARTBEAT.md empty or missing)")
|
||||
return
|
||||
}
|
||||
|
||||
if handler == nil {
|
||||
hs.logError("Heartbeat handler not configured")
|
||||
return
|
||||
}
|
||||
|
||||
// Get last channel info for context
|
||||
lastChannel := hs.state.GetLastChannel()
|
||||
channel, chatID := hs.parseLastChannel(lastChannel)
|
||||
|
||||
// Debug log for channel resolution
|
||||
hs.logInfo("Resolved channel: %s, chatID: %s (from lastChannel: %s)", channel, chatID, lastChannel)
|
||||
|
||||
result := handler(prompt, channel, chatID)
|
||||
|
||||
if result == nil {
|
||||
hs.logInfo("Heartbeat handler returned nil result")
|
||||
return
|
||||
}
|
||||
|
||||
// Handle different result types
|
||||
if result.IsError {
|
||||
hs.logError("Heartbeat error: %s", result.ForLLM)
|
||||
return
|
||||
}
|
||||
|
||||
if result.Async {
|
||||
hs.logInfo("Async task started: %s", result.ForLLM)
|
||||
logger.InfoCF("heartbeat", "Async heartbeat task started",
|
||||
map[string]interface{}{
|
||||
"message": result.ForLLM,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if silent
|
||||
if result.Silent {
|
||||
hs.logInfo("Heartbeat OK - silent")
|
||||
return
|
||||
}
|
||||
|
||||
// Send result to user
|
||||
if result.ForUser != "" {
|
||||
hs.sendResponse(result.ForUser)
|
||||
} else if result.ForLLM != "" {
|
||||
hs.sendResponse(result.ForLLM)
|
||||
}
|
||||
|
||||
hs.logInfo("Heartbeat completed: %s", result.ForLLM)
|
||||
}
|
||||
|
||||
// buildPrompt builds the heartbeat prompt from HEARTBEAT.md
|
||||
func (hs *HeartbeatService) buildPrompt() string {
|
||||
notesDir := filepath.Join(hs.workspace, "memory")
|
||||
notesFile := filepath.Join(notesDir, "HEARTBEAT.md")
|
||||
heartbeatPath := filepath.Join(hs.workspace, "HEARTBEAT.md")
|
||||
|
||||
var notes string
|
||||
if data, err := os.ReadFile(notesFile); err == nil {
|
||||
notes = string(data)
|
||||
data, err := os.ReadFile(heartbeatPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
hs.createDefaultHeartbeatTemplate()
|
||||
return ""
|
||||
}
|
||||
hs.logError("Error reading HEARTBEAT.md: %v", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
now := time.Now().Format("2006-01-02 15:04")
|
||||
content := string(data)
|
||||
if len(content) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`# Heartbeat Check
|
||||
now := time.Now().Format("2006-01-02 15:04:05")
|
||||
return fmt.Sprintf(`# Heartbeat Check
|
||||
|
||||
Current time: %s
|
||||
|
||||
Check if there are any tasks I should be aware of or actions I should take.
|
||||
Review the memory file for any important updates or changes.
|
||||
Be proactive in identifying potential issues or improvements.
|
||||
You are a proactive AI assistant. This is a scheduled heartbeat check.
|
||||
Review the following tasks and execute any necessary actions using available skills.
|
||||
If there is nothing that requires attention, respond ONLY with: HEARTBEAT_OK
|
||||
|
||||
%s
|
||||
`, now, notes)
|
||||
|
||||
return prompt
|
||||
`, now, content)
|
||||
}
|
||||
|
||||
func (hs *HeartbeatService) log(message string) {
|
||||
logFile := filepath.Join(hs.workspace, "memory", "heartbeat.log")
|
||||
// createDefaultHeartbeatTemplate creates the default HEARTBEAT.md file
|
||||
func (hs *HeartbeatService) createDefaultHeartbeatTemplate() {
|
||||
heartbeatPath := filepath.Join(hs.workspace, "HEARTBEAT.md")
|
||||
|
||||
defaultContent := `# Heartbeat Check List
|
||||
|
||||
This file contains tasks for the heartbeat service to check periodically.
|
||||
|
||||
## Examples
|
||||
|
||||
- Check for unread messages
|
||||
- Review upcoming calendar events
|
||||
- Check device status (e.g., MaixCam)
|
||||
|
||||
## Instructions
|
||||
|
||||
- Execute ALL tasks listed below. Do NOT skip any task.
|
||||
- For simple tasks (e.g., report current time), respond directly.
|
||||
- For complex tasks that may take time, use the spawn tool to create a subagent.
|
||||
- The spawn tool is async - subagent results will be sent to the user automatically.
|
||||
- After spawning a subagent, CONTINUE to process remaining tasks.
|
||||
- Only respond with HEARTBEAT_OK when ALL tasks are done AND nothing needs attention.
|
||||
|
||||
---
|
||||
|
||||
Add your heartbeat tasks below this line:
|
||||
`
|
||||
|
||||
if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0644); err != nil {
|
||||
hs.logError("Failed to create default HEARTBEAT.md: %v", err)
|
||||
} else {
|
||||
hs.logInfo("Created default HEARTBEAT.md template")
|
||||
}
|
||||
}
|
||||
|
||||
// sendResponse sends the heartbeat response to the last channel
|
||||
func (hs *HeartbeatService) sendResponse(response string) {
|
||||
hs.mu.RLock()
|
||||
msgBus := hs.bus
|
||||
hs.mu.RUnlock()
|
||||
|
||||
if msgBus == nil {
|
||||
hs.logInfo("No message bus configured, heartbeat result not sent")
|
||||
return
|
||||
}
|
||||
|
||||
// Get last channel from state
|
||||
lastChannel := hs.state.GetLastChannel()
|
||||
if lastChannel == "" {
|
||||
hs.logInfo("No last channel recorded, heartbeat result not sent")
|
||||
return
|
||||
}
|
||||
|
||||
platform, userID := hs.parseLastChannel(lastChannel)
|
||||
|
||||
// Skip internal channels that can't receive messages
|
||||
if platform == "" || userID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
msgBus.PublishOutbound(bus.OutboundMessage{
|
||||
Channel: platform,
|
||||
ChatID: userID,
|
||||
Content: response,
|
||||
})
|
||||
|
||||
hs.logInfo("Heartbeat result sent to %s", platform)
|
||||
}
|
||||
|
||||
// parseLastChannel parses the last channel string into platform and userID.
|
||||
// Returns empty strings for invalid or internal channels.
|
||||
func (hs *HeartbeatService) parseLastChannel(lastChannel string) (platform, userID string) {
|
||||
if lastChannel == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Parse channel format: "platform:user_id" (e.g., "telegram:123456")
|
||||
parts := strings.SplitN(lastChannel, ":", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
hs.logError("Invalid last channel format: %s", lastChannel)
|
||||
return "", ""
|
||||
}
|
||||
|
||||
platform, userID = parts[0], parts[1]
|
||||
|
||||
// Skip internal channels
|
||||
if constants.IsInternalChannel(platform) {
|
||||
hs.logInfo("Skipping internal channel: %s", platform)
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return platform, userID
|
||||
}
|
||||
|
||||
// logInfo logs an informational message to the heartbeat log
|
||||
func (hs *HeartbeatService) logInfo(format string, args ...any) {
|
||||
hs.log("INFO", format, args...)
|
||||
}
|
||||
|
||||
// logError logs an error message to the heartbeat log
|
||||
func (hs *HeartbeatService) logError(format string, args ...any) {
|
||||
hs.log("ERROR", format, args...)
|
||||
}
|
||||
|
||||
// log writes a message to the heartbeat log file
|
||||
func (hs *HeartbeatService) log(level, format string, args ...any) {
|
||||
logFile := filepath.Join(hs.workspace, "heartbeat.log")
|
||||
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return
|
||||
|
|
@ -130,5 +361,5 @@ func (hs *HeartbeatService) log(message string) {
|
|||
defer f.Close()
|
||||
|
||||
timestamp := time.Now().Format("2006-01-02 15:04:05")
|
||||
f.WriteString(fmt.Sprintf("[%s] %s\n", timestamp, message))
|
||||
fmt.Fprintf(f, "[%s] [%s] %s\n", timestamp, level, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
|
|
|||
221
pkg/heartbeat/service_test.go
Normal file
221
pkg/heartbeat/service_test.go
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
package heartbeat
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
func TestExecuteHeartbeat_Async(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||
|
||||
asyncCalled := false
|
||||
asyncResult := &tools.ToolResult{
|
||||
ForLLM: "Background task started",
|
||||
ForUser: "Task started in background",
|
||||
Silent: false,
|
||||
IsError: false,
|
||||
Async: true,
|
||||
}
|
||||
|
||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
asyncCalled = true
|
||||
if prompt == "" {
|
||||
t.Error("Expected non-empty prompt")
|
||||
}
|
||||
return asyncResult
|
||||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
|
||||
// Execute heartbeat directly (internal method for testing)
|
||||
hs.executeHeartbeat()
|
||||
|
||||
if !asyncCalled {
|
||||
t.Error("Expected handler to be called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHeartbeat_Error(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||
|
||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
return &tools.ToolResult{
|
||||
ForLLM: "Heartbeat failed: connection error",
|
||||
ForUser: "",
|
||||
Silent: false,
|
||||
IsError: true,
|
||||
Async: false,
|
||||
}
|
||||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
|
||||
hs.executeHeartbeat()
|
||||
|
||||
// Check log file for error message
|
||||
logFile := filepath.Join(tmpDir, "heartbeat.log")
|
||||
data, err := os.ReadFile(logFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read log file: %v", err)
|
||||
}
|
||||
|
||||
logContent := string(data)
|
||||
if logContent == "" {
|
||||
t.Error("Expected log file to contain error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHeartbeat_Silent(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||
|
||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
return &tools.ToolResult{
|
||||
ForLLM: "Heartbeat completed successfully",
|
||||
ForUser: "",
|
||||
Silent: true,
|
||||
IsError: false,
|
||||
Async: false,
|
||||
}
|
||||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
|
||||
hs.executeHeartbeat()
|
||||
|
||||
// Check log file for completion message
|
||||
logFile := filepath.Join(tmpDir, "heartbeat.log")
|
||||
data, err := os.ReadFile(logFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read log file: %v", err)
|
||||
}
|
||||
|
||||
logContent := string(data)
|
||||
if logContent == "" {
|
||||
t.Error("Expected log file to contain completion message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatService_StartStop(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 1, true)
|
||||
|
||||
err = hs.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to start heartbeat service: %v", err)
|
||||
}
|
||||
|
||||
hs.Stop()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
func TestHeartbeatService_Disabled(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 1, false)
|
||||
|
||||
if hs.enabled != false {
|
||||
t.Error("Expected service to be disabled")
|
||||
}
|
||||
|
||||
err = hs.Start()
|
||||
_ = err // Disabled service returns nil
|
||||
}
|
||||
|
||||
func TestExecuteHeartbeat_NilResult(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
hs.stopChan = make(chan struct{}) // Enable for testing
|
||||
|
||||
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
|
||||
return nil
|
||||
})
|
||||
|
||||
// Create HEARTBEAT.md
|
||||
os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0644)
|
||||
|
||||
// Should not panic with nil result
|
||||
hs.executeHeartbeat()
|
||||
}
|
||||
|
||||
// TestLogPath verifies heartbeat log is written to workspace directory
|
||||
func TestLogPath(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
|
||||
// Write a log entry
|
||||
hs.log("INFO", "Test log entry")
|
||||
|
||||
// Verify log file exists at workspace root
|
||||
expectedLogPath := filepath.Join(tmpDir, "heartbeat.log")
|
||||
if _, err := os.Stat(expectedLogPath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected log file at %s, but it doesn't exist", expectedLogPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHeartbeatFilePath verifies HEARTBEAT.md is at workspace root
|
||||
func TestHeartbeatFilePath(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
hs := NewHeartbeatService(tmpDir, 30, true)
|
||||
|
||||
// Trigger default template creation
|
||||
hs.buildPrompt()
|
||||
|
||||
// Verify HEARTBEAT.md exists at workspace root
|
||||
expectedPath := filepath.Join(tmpDir, "HEARTBEAT.md")
|
||||
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
||||
t.Errorf("Expected HEARTBEAT.md at %s, but it doesn't exist", expectedPath)
|
||||
}
|
||||
}
|
||||
|
|
@ -108,7 +108,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":
|
||||
|
|
@ -212,12 +215,17 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
|
|||
|
||||
if tools, ok := getMap(data, "tools"); ok {
|
||||
if web, ok := getMap(tools, "web"); ok {
|
||||
// Migrate old "search" config to "brave" if api_key is present
|
||||
if search, ok := getMap(web, "search"); ok {
|
||||
if v, ok := getString(search, "api_key"); ok {
|
||||
cfg.Tools.Web.Search.APIKey = v
|
||||
cfg.Tools.Web.Brave.APIKey = v
|
||||
if v != "" {
|
||||
cfg.Tools.Web.Brave.Enabled = true
|
||||
}
|
||||
}
|
||||
if v, ok := getFloat(search, "max_results"); ok {
|
||||
cfg.Tools.Web.Search.MaxResults = int(v)
|
||||
cfg.Tools.Web.Brave.MaxResults = int(v)
|
||||
cfg.Tools.Web.DuckDuckGo.MaxResults = int(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -271,8 +279,8 @@ func MergeConfig(existing, incoming *config.Config) *config.Config {
|
|||
existing.Channels.MaixCam = incoming.Channels.MaixCam
|
||||
}
|
||||
|
||||
if existing.Tools.Web.Search.APIKey == "" {
|
||||
existing.Tools.Web.Search = incoming.Tools.Web.Search
|
||||
if existing.Tools.Web.Brave.APIKey == "" {
|
||||
existing.Tools.Web.Brave = incoming.Tools.Web.Brave
|
||||
}
|
||||
|
||||
return existing
|
||||
|
|
@ -358,6 +366,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 {
|
||||
|
|
|
|||
|
|
@ -299,6 +299,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()
|
||||
|
|
|
|||
248
pkg/providers/anthropic/provider.go
Normal file
248
pkg/providers/anthropic/provider.go
Normal file
|
|
@ -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-5-20250929"
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
265
pkg/providers/anthropic/provider_test.go
Normal file
265
pkg/providers/anthropic/provider_test.go
Normal file
|
|
@ -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-5-20250929", map[string]interface{}{
|
||||
"max_tokens": 1024,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildParams() 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 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-5-20250929", 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-5-20250929", 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-5-20250929", 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-5-20250929", 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-5-20250929" {
|
||||
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4-5-20250929")
|
||||
}
|
||||
}
|
||||
|
||||
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-5-20250929", 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
|
||||
}
|
||||
221
pkg/providers/claude_cli_provider.go
Normal file
221
pkg/providers/claude_cli_provider.go
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
package providers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess.
|
||||
type ClaudeCliProvider struct {
|
||||
command string
|
||||
workspace string
|
||||
}
|
||||
|
||||
// NewClaudeCliProvider creates a new Claude CLI provider.
|
||||
func NewClaudeCliProvider(workspace string) *ClaudeCliProvider {
|
||||
return &ClaudeCliProvider{
|
||||
command: "claude",
|
||||
workspace: workspace,
|
||||
}
|
||||
}
|
||||
|
||||
// Chat implements LLMProvider.Chat by executing the claude CLI.
|
||||
func (p *ClaudeCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
systemPrompt := p.buildSystemPrompt(messages, tools)
|
||||
prompt := p.messagesToPrompt(messages)
|
||||
|
||||
args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"}
|
||||
if systemPrompt != "" {
|
||||
args = append(args, "--system-prompt", systemPrompt)
|
||||
}
|
||||
if model != "" && model != "claude-code" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
args = append(args, "-") // read from stdin
|
||||
|
||||
cmd := exec.CommandContext(ctx, p.command, args...)
|
||||
if p.workspace != "" {
|
||||
cmd.Dir = p.workspace
|
||||
}
|
||||
cmd.Stdin = bytes.NewReader([]byte(prompt))
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if stderrStr := stderr.String(); stderrStr != "" {
|
||||
return nil, fmt.Errorf("claude cli error: %s", stderrStr)
|
||||
}
|
||||
return nil, fmt.Errorf("claude cli error: %w", err)
|
||||
}
|
||||
|
||||
return p.parseClaudeCliResponse(stdout.String())
|
||||
}
|
||||
|
||||
// GetDefaultModel returns the default model identifier.
|
||||
func (p *ClaudeCliProvider) GetDefaultModel() string {
|
||||
return "claude-code"
|
||||
}
|
||||
|
||||
// messagesToPrompt converts messages to a CLI-compatible prompt string.
|
||||
func (p *ClaudeCliProvider) messagesToPrompt(messages []Message) string {
|
||||
var parts []string
|
||||
|
||||
for _, msg := range messages {
|
||||
switch msg.Role {
|
||||
case "system":
|
||||
// handled via --system-prompt flag
|
||||
case "user":
|
||||
parts = append(parts, "User: "+msg.Content)
|
||||
case "assistant":
|
||||
parts = append(parts, "Assistant: "+msg.Content)
|
||||
case "tool":
|
||||
parts = append(parts, fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content))
|
||||
}
|
||||
}
|
||||
|
||||
// Simplify single user message
|
||||
if len(parts) == 1 && strings.HasPrefix(parts[0], "User: ") {
|
||||
return strings.TrimPrefix(parts[0], "User: ")
|
||||
}
|
||||
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
// buildSystemPrompt combines system messages and tool definitions.
|
||||
func (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDefinition) string {
|
||||
var parts []string
|
||||
|
||||
for _, msg := range messages {
|
||||
if msg.Role == "system" {
|
||||
parts = append(parts, msg.Content)
|
||||
}
|
||||
}
|
||||
|
||||
if len(tools) > 0 {
|
||||
parts = append(parts, p.buildToolsPrompt(tools))
|
||||
}
|
||||
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// buildToolsPrompt creates the tool definitions section for the system prompt.
|
||||
func (p *ClaudeCliProvider) 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()
|
||||
}
|
||||
|
||||
// parseClaudeCliResponse parses the JSON output from the claude CLI.
|
||||
func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) {
|
||||
var resp claudeCliJSONResponse
|
||||
if err := json.Unmarshal([]byte(output), &resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse claude cli response: %w", err)
|
||||
}
|
||||
|
||||
if resp.IsError {
|
||||
return nil, fmt.Errorf("claude cli returned error: %s", resp.Result)
|
||||
}
|
||||
|
||||
toolCalls := p.extractToolCalls(resp.Result)
|
||||
|
||||
finishReason := "stop"
|
||||
content := resp.Result
|
||||
if len(toolCalls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
content = p.stripToolCallsJSON(resp.Result)
|
||||
}
|
||||
|
||||
var usage *UsageInfo
|
||||
if resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0 {
|
||||
usage = &UsageInfo{
|
||||
PromptTokens: resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens,
|
||||
CompletionTokens: resp.Usage.OutputTokens,
|
||||
TotalTokens: resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens + resp.Usage.OutputTokens,
|
||||
}
|
||||
}
|
||||
|
||||
return &LLMResponse{
|
||||
Content: strings.TrimSpace(content),
|
||||
ToolCalls: toolCalls,
|
||||
FinishReason: finishReason,
|
||||
Usage: usage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractToolCalls delegates to the shared extractToolCallsFromText function.
|
||||
func (p *ClaudeCliProvider) extractToolCalls(text string) []ToolCall {
|
||||
return extractToolCallsFromText(text)
|
||||
}
|
||||
|
||||
// stripToolCallsJSON delegates to the shared stripToolCallsFromText function.
|
||||
func (p *ClaudeCliProvider) stripToolCallsJSON(text string) string {
|
||||
return stripToolCallsFromText(text)
|
||||
}
|
||||
|
||||
// findMatchingBrace finds the index after the closing brace matching the opening brace at pos.
|
||||
func findMatchingBrace(text string, pos int) int {
|
||||
depth := 0
|
||||
for i := pos; i < len(text); i++ {
|
||||
if text[i] == '{' {
|
||||
depth++
|
||||
} else if text[i] == '}' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
// claudeCliJSONResponse represents the JSON output from the claude CLI.
|
||||
// Matches the real claude CLI v2.x output format.
|
||||
type claudeCliJSONResponse struct {
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype"`
|
||||
IsError bool `json:"is_error"`
|
||||
Result string `json:"result"`
|
||||
SessionID string `json:"session_id"`
|
||||
TotalCostUSD float64 `json:"total_cost_usd"`
|
||||
DurationMS int `json:"duration_ms"`
|
||||
DurationAPI int `json:"duration_api_ms"`
|
||||
NumTurns int `json:"num_turns"`
|
||||
Usage claudeCliUsageInfo `json:"usage"`
|
||||
}
|
||||
|
||||
// claudeCliUsageInfo represents token usage from the claude CLI response.
|
||||
type claudeCliUsageInfo struct {
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
CacheCreationInputTokens int `json:"cache_creation_input_tokens"`
|
||||
CacheReadInputTokens int `json:"cache_read_input_tokens"`
|
||||
}
|
||||
126
pkg/providers/claude_cli_provider_integration_test.go
Normal file
126
pkg/providers/claude_cli_provider_integration_test.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
//go:build integration
|
||||
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
exec "os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestIntegration_RealClaudeCLI tests the ClaudeCliProvider with a real claude CLI.
|
||||
// Run with: go test -tags=integration ./pkg/providers/...
|
||||
func TestIntegration_RealClaudeCLI(t *testing.T) {
|
||||
// Check if claude CLI is available
|
||||
path, err := exec.LookPath("claude")
|
||||
if err != nil {
|
||||
t.Skip("claude CLI not found in PATH, skipping integration test")
|
||||
}
|
||||
t.Logf("Using claude CLI at: %s", path)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*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)
|
||||
}
|
||||
|
||||
// Verify response structure
|
||||
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.Error("Usage should not be nil from real CLI")
|
||||
} else {
|
||||
if resp.Usage.PromptTokens == 0 {
|
||||
t.Error("PromptTokens should be > 0")
|
||||
}
|
||||
if resp.Usage.CompletionTokens == 0 {
|
||||
t.Error("CompletionTokens should be > 0")
|
||||
}
|
||||
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)
|
||||
|
||||
// Loose check - should contain "pong" somewhere (model might capitalize or add punctuation)
|
||||
if !strings.Contains(strings.ToLower(resp.Content), "pong") {
|
||||
t.Errorf("Content = %q, expected to contain 'pong'", resp.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_RealClaudeCLI_WithSystemPrompt(t *testing.T) {
|
||||
if _, err := exec.LookPath("claude"); err != nil {
|
||||
t.Skip("claude CLI not found in PATH")
|
||||
}
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*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_RealClaudeCLI_ParsesRealJSON(t *testing.T) {
|
||||
if _, err := exec.LookPath("claude"); err != nil {
|
||||
t.Skip("claude CLI not found in PATH")
|
||||
}
|
||||
|
||||
// Run claude directly and verify our parser handles real output
|
||||
cmd := exec.Command("claude", "-p", "--output-format", "json",
|
||||
"--dangerously-skip-permissions", "--no-chrome", "--no-session-persistence", "-")
|
||||
cmd.Stdin = strings.NewReader("Say hi")
|
||||
cmd.Dir = t.TempDir()
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("claude CLI failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Raw CLI output: %s", string(output))
|
||||
|
||||
// Verify our parser can handle real output
|
||||
p := NewClaudeCliProvider("")
|
||||
resp, err := p.parseClaudeCliResponse(string(output))
|
||||
if err != nil {
|
||||
t.Fatalf("parseClaudeCliResponse() 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)
|
||||
}
|
||||
if resp.Usage == nil {
|
||||
t.Error("Usage should not be nil")
|
||||
}
|
||||
|
||||
t.Logf("Parsed: content=%q, finish=%s, usage=%+v", resp.Content, resp.FinishReason, resp.Usage)
|
||||
}
|
||||
981
pkg/providers/claude_cli_provider_test.go
Normal file
981
pkg/providers/claude_cli_provider_test.go
Normal file
|
|
@ -0,0 +1,981 @@
|
|||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
// --- Compile-time interface check ---
|
||||
|
||||
var _ LLMProvider = (*ClaudeCliProvider)(nil)
|
||||
|
||||
// --- Helper: create mock CLI scripts ---
|
||||
|
||||
// createMockCLI creates a temporary script that simulates the claude CLI.
|
||||
// Uses files for stdout/stderr to avoid shell quoting issues with JSON.
|
||||
func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock CLI scripts not supported on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
if stdout != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if stderr != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("#!/bin/sh\n")
|
||||
if stderr != "" {
|
||||
sb.WriteString(fmt.Sprintf("cat '%s/stderr.txt' >&2\n", dir))
|
||||
}
|
||||
if stdout != "" {
|
||||
sb.WriteString(fmt.Sprintf("cat '%s/stdout.txt'\n", dir))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("exit %d\n", exitCode))
|
||||
|
||||
script := filepath.Join(dir, "claude")
|
||||
if err := os.WriteFile(script, []byte(sb.String()), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
// createSlowMockCLI creates a script that sleeps before responding (for context cancellation tests).
|
||||
func createSlowMockCLI(t *testing.T, sleepSeconds int) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock CLI scripts not supported on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "claude")
|
||||
content := fmt.Sprintf("#!/bin/sh\nsleep %d\necho '{\"type\":\"result\",\"result\":\"late\"}'\n", sleepSeconds)
|
||||
if err := os.WriteFile(script, []byte(content), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
// createArgCaptureCLI creates a script that captures CLI args to a file, then outputs JSON.
|
||||
func createArgCaptureCLI(t *testing.T, argsFile string) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("mock CLI scripts not supported on Windows")
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
script := filepath.Join(dir, "claude")
|
||||
content := fmt.Sprintf(`#!/bin/sh
|
||||
echo "$@" > '%s'
|
||||
cat <<'EOFMOCK'
|
||||
{"type":"result","result":"ok","session_id":"test"}
|
||||
EOFMOCK
|
||||
`, argsFile)
|
||||
if err := os.WriteFile(script, []byte(content), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return script
|
||||
}
|
||||
|
||||
// --- Constructor tests ---
|
||||
|
||||
func TestNewClaudeCliProvider(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/test/workspace")
|
||||
if p == nil {
|
||||
t.Fatal("NewClaudeCliProvider returned nil")
|
||||
}
|
||||
if p.workspace != "/test/workspace" {
|
||||
t.Errorf("workspace = %q, want %q", p.workspace, "/test/workspace")
|
||||
}
|
||||
if p.command != "claude" {
|
||||
t.Errorf("command = %q, want %q", p.command, "claude")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClaudeCliProvider_EmptyWorkspace(t *testing.T) {
|
||||
p := NewClaudeCliProvider("")
|
||||
if p.workspace != "" {
|
||||
t.Errorf("workspace = %q, want empty", p.workspace)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetDefaultModel tests ---
|
||||
|
||||
func TestClaudeCliProvider_GetDefaultModel(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
if got := p.GetDefaultModel(); got != "claude-code" {
|
||||
t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-code")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Chat() tests ---
|
||||
|
||||
func TestChat_Success(t *testing.T) {
|
||||
mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Hello from mock!","session_id":"sess_123","total_cost_usd":0.005,"duration_ms":200,"duration_api_ms":150,"num_turns":1,"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":100,"cache_read_input_tokens":0}}`
|
||||
script := createMockCLI(t, mockJSON, "", 0)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
if resp.Content != "Hello from mock!" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Hello from mock!")
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
if len(resp.ToolCalls) != 0 {
|
||||
t.Errorf("ToolCalls len = %d, want 0", len(resp.ToolCalls))
|
||||
}
|
||||
if resp.Usage == nil {
|
||||
t.Fatal("Usage should not be nil")
|
||||
}
|
||||
if resp.Usage.PromptTokens != 110 { // 10 + 100 + 0
|
||||
t.Errorf("PromptTokens = %d, want 110", resp.Usage.PromptTokens)
|
||||
}
|
||||
if resp.Usage.CompletionTokens != 5 {
|
||||
t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens)
|
||||
}
|
||||
if resp.Usage.TotalTokens != 115 { // 110 + 5
|
||||
t.Errorf("TotalTokens = %d, want 115", resp.Usage.TotalTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_IsErrorResponse(t *testing.T) {
|
||||
mockJSON := `{"type":"result","subtype":"error","is_error":true,"result":"Rate limit exceeded","session_id":"s1","total_cost_usd":0}`
|
||||
script := createMockCLI(t, mockJSON, "", 0)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error when is_error=true")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Rate limit exceeded") {
|
||||
t.Errorf("error = %q, want to contain 'Rate limit exceeded'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_WithToolCallsInResponse(t *testing.T) {
|
||||
mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Checking weather.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"NYC\\\"}\"}}]}","session_id":"s1","total_cost_usd":0.01,"usage":{"input_tokens":5,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}`
|
||||
script := createMockCLI(t, mockJSON, "", 0)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() 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 len = %d, want 1", len(resp.ToolCalls))
|
||||
}
|
||||
if resp.ToolCalls[0].Name != "get_weather" {
|
||||
t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "get_weather")
|
||||
}
|
||||
if resp.ToolCalls[0].Arguments["location"] != "NYC" {
|
||||
t.Errorf("ToolCalls[0].Arguments[location] = %v, want NYC", resp.ToolCalls[0].Arguments["location"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_StderrError(t *testing.T) {
|
||||
script := createMockCLI(t, "", "Error: rate limited", 1)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rate limited") {
|
||||
t.Errorf("error = %q, want to contain 'rate limited'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_NonZeroExitNoStderr(t *testing.T) {
|
||||
script := createMockCLI(t, "", "", 1)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error for non-zero exit")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "claude cli error") {
|
||||
t.Errorf("error = %q, want to contain 'claude cli error'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_CommandNotFound(t *testing.T) {
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = "/nonexistent/claude-binary-that-does-not-exist"
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error for missing command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_InvalidResponseJSON(t *testing.T) {
|
||||
script := createMockCLI(t, "not valid json at all", "", 0)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error for invalid JSON")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to parse claude cli response") {
|
||||
t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_ContextCancellation(t *testing.T) {
|
||||
script := createSlowMockCLI(t, 2) // sleep 2s
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
_, err := p.Chat(ctx, []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("Chat() expected error on context cancellation")
|
||||
}
|
||||
// Should fail well before the full 2s sleep completes
|
||||
if elapsed > 3*time.Second {
|
||||
t.Errorf("Chat() took %v, expected to fail faster via context cancellation", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_PassesSystemPromptFlag(t *testing.T) {
|
||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
||||
script := createArgCaptureCLI(t, argsFile)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "system", Content: "Be helpful."},
|
||||
{Role: "user", Content: "Hi"},
|
||||
}, nil, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
argsBytes, err := os.ReadFile(argsFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read args file: %v", err)
|
||||
}
|
||||
args := string(argsBytes)
|
||||
if !strings.Contains(args, "--system-prompt") {
|
||||
t.Errorf("CLI args missing --system-prompt, got: %s", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_PassesModelFlag(t *testing.T) {
|
||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
||||
script := createArgCaptureCLI(t, argsFile)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
}, nil, "claude-sonnet-4-5-20250929", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
argsBytes, _ := os.ReadFile(argsFile)
|
||||
args := string(argsBytes)
|
||||
if !strings.Contains(args, "--model") {
|
||||
t.Errorf("CLI args missing --model, got: %s", args)
|
||||
}
|
||||
if !strings.Contains(args, "claude-sonnet-4-5-20250929") {
|
||||
t.Errorf("CLI args missing model name, got: %s", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_SkipsModelFlagForClaudeCode(t *testing.T) {
|
||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
||||
script := createArgCaptureCLI(t, argsFile)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
}, nil, "claude-code", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
argsBytes, _ := os.ReadFile(argsFile)
|
||||
args := string(argsBytes)
|
||||
if strings.Contains(args, "--model") {
|
||||
t.Errorf("CLI args should NOT contain --model for claude-code, got: %s", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_SkipsModelFlagForEmptyModel(t *testing.T) {
|
||||
argsFile := filepath.Join(t.TempDir(), "args.txt")
|
||||
script := createArgCaptureCLI(t, argsFile)
|
||||
|
||||
p := NewClaudeCliProvider(t.TempDir())
|
||||
p.command = script
|
||||
|
||||
_, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
}, nil, "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() error = %v", err)
|
||||
}
|
||||
|
||||
argsBytes, _ := os.ReadFile(argsFile)
|
||||
args := string(argsBytes)
|
||||
if strings.Contains(args, "--model") {
|
||||
t.Errorf("CLI args should NOT contain --model for empty model, got: %s", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) {
|
||||
mockJSON := `{"type":"result","result":"ok","session_id":"s"}`
|
||||
script := createMockCLI(t, mockJSON, "", 0)
|
||||
|
||||
p := NewClaudeCliProvider("")
|
||||
p.command = script
|
||||
|
||||
resp, err := p.Chat(context.Background(), []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}, nil, "", nil)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Chat() with empty workspace error = %v", err)
|
||||
}
|
||||
if resp.Content != "ok" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "ok")
|
||||
}
|
||||
}
|
||||
|
||||
// --- CreateProvider factory tests ---
|
||||
|
||||
func TestCreateProvider_ClaudeCli(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Provider = "claude-cli"
|
||||
cfg.Agents.Defaults.Workspace = "/test/ws"
|
||||
|
||||
provider, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider(claude-cli) error = %v", err)
|
||||
}
|
||||
|
||||
cliProvider, ok := provider.(*ClaudeCliProvider)
|
||||
if !ok {
|
||||
t.Fatalf("CreateProvider(claude-cli) returned %T, want *ClaudeCliProvider", provider)
|
||||
}
|
||||
if cliProvider.workspace != "/test/ws" {
|
||||
t.Errorf("workspace = %q, want %q", cliProvider.workspace, "/test/ws")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProvider_ClaudeCode(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Provider = "claude-code"
|
||||
|
||||
provider, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider(claude-code) error = %v", err)
|
||||
}
|
||||
if _, ok := provider.(*ClaudeCliProvider); !ok {
|
||||
t.Fatalf("CreateProvider(claude-code) returned %T, want *ClaudeCliProvider", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProvider_ClaudeCodec(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Provider = "claudecode"
|
||||
|
||||
provider, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider(claudecode) error = %v", err)
|
||||
}
|
||||
if _, ok := provider.(*ClaudeCliProvider); !ok {
|
||||
t.Fatalf("CreateProvider(claudecode) returned %T, want *ClaudeCliProvider", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Provider = "claude-cli"
|
||||
cfg.Agents.Defaults.Workspace = ""
|
||||
|
||||
provider, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider error = %v", err)
|
||||
}
|
||||
|
||||
cliProvider, ok := provider.(*ClaudeCliProvider)
|
||||
if !ok {
|
||||
t.Fatalf("returned %T, want *ClaudeCliProvider", provider)
|
||||
}
|
||||
if cliProvider.workspace != "." {
|
||||
t.Errorf("workspace = %q, want %q (default)", cliProvider.workspace, ".")
|
||||
}
|
||||
}
|
||||
|
||||
// --- messagesToPrompt tests ---
|
||||
|
||||
func TestMessagesToPrompt_SingleUser(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Hello"},
|
||||
}
|
||||
got := p.messagesToPrompt(messages)
|
||||
want := "Hello"
|
||||
if got != want {
|
||||
t.Errorf("messagesToPrompt() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesToPrompt_Conversation(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
{Role: "assistant", Content: "Hello!"},
|
||||
{Role: "user", Content: "How are you?"},
|
||||
}
|
||||
got := p.messagesToPrompt(messages)
|
||||
want := "User: Hi\nAssistant: Hello!\nUser: How are you?"
|
||||
if got != want {
|
||||
t.Errorf("messagesToPrompt() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesToPrompt_WithSystemMessage(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "user", Content: "Hello"},
|
||||
}
|
||||
got := p.messagesToPrompt(messages)
|
||||
want := "Hello"
|
||||
if got != want {
|
||||
t.Errorf("messagesToPrompt() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesToPrompt_WithToolResults(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "What's the weather?"},
|
||||
{Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_123"},
|
||||
}
|
||||
got := p.messagesToPrompt(messages)
|
||||
if !strings.Contains(got, "[Tool Result for call_123]") {
|
||||
t.Errorf("messagesToPrompt() missing tool result marker, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `{"temp": 72}`) {
|
||||
t.Errorf("messagesToPrompt() missing tool result content, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesToPrompt_EmptyMessages(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
got := p.messagesToPrompt(nil)
|
||||
if got != "" {
|
||||
t.Errorf("messagesToPrompt(nil) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessagesToPrompt_OnlySystemMessages(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "System 1"},
|
||||
{Role: "system", Content: "System 2"},
|
||||
}
|
||||
got := p.messagesToPrompt(messages)
|
||||
if got != "" {
|
||||
t.Errorf("messagesToPrompt() with only system msgs = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- buildSystemPrompt tests ---
|
||||
|
||||
func TestBuildSystemPrompt_NoSystemNoTools(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "user", Content: "Hi"},
|
||||
}
|
||||
got := p.buildSystemPrompt(messages, nil)
|
||||
if got != "" {
|
||||
t.Errorf("buildSystemPrompt() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt_SystemOnly(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "user", Content: "Hi"},
|
||||
}
|
||||
got := p.buildSystemPrompt(messages, nil)
|
||||
if got != "You are helpful." {
|
||||
t.Errorf("buildSystemPrompt() = %q, want %q", got, "You are helpful.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt_MultipleSystemMessages(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
{Role: "system", Content: "Be concise."},
|
||||
{Role: "user", Content: "Hi"},
|
||||
}
|
||||
got := p.buildSystemPrompt(messages, nil)
|
||||
if !strings.Contains(got, "You are helpful.") {
|
||||
t.Error("missing first system message")
|
||||
}
|
||||
if !strings.Contains(got, "Be concise.") {
|
||||
t.Error("missing second system message")
|
||||
}
|
||||
// Should be joined with double newline
|
||||
want := "You are helpful.\n\nBe concise."
|
||||
if got != want {
|
||||
t.Errorf("buildSystemPrompt() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt_WithTools(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
messages := []Message{
|
||||
{Role: "system", Content: "You are helpful."},
|
||||
}
|
||||
tools := []ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: ToolFunctionDefinition{
|
||||
Name: "get_weather",
|
||||
Description: "Get weather for a location",
|
||||
Parameters: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"location": map[string]interface{}{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := p.buildSystemPrompt(messages, tools)
|
||||
if !strings.Contains(got, "You are helpful.") {
|
||||
t.Error("buildSystemPrompt() missing system message")
|
||||
}
|
||||
if !strings.Contains(got, "get_weather") {
|
||||
t.Error("buildSystemPrompt() missing tool definition")
|
||||
}
|
||||
if !strings.Contains(got, "Available Tools") {
|
||||
t.Error("buildSystemPrompt() missing tools header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt_ToolsOnlyNoSystem(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
tools := []ToolDefinition{
|
||||
{
|
||||
Type: "function",
|
||||
Function: ToolFunctionDefinition{
|
||||
Name: "test_tool",
|
||||
Description: "A test tool",
|
||||
},
|
||||
},
|
||||
}
|
||||
got := p.buildSystemPrompt(nil, tools)
|
||||
if !strings.Contains(got, "test_tool") {
|
||||
t.Error("should include tool definitions even without system messages")
|
||||
}
|
||||
}
|
||||
|
||||
// --- buildToolsPrompt tests ---
|
||||
|
||||
func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
tools := []ToolDefinition{
|
||||
{Type: "other", Function: ToolFunctionDefinition{Name: "skip_me"}},
|
||||
{Type: "function", Function: ToolFunctionDefinition{Name: "include_me", Description: "Included"}},
|
||||
}
|
||||
got := p.buildToolsPrompt(tools)
|
||||
if strings.Contains(got, "skip_me") {
|
||||
t.Error("buildToolsPrompt() should skip non-function tools")
|
||||
}
|
||||
if !strings.Contains(got, "include_me") {
|
||||
t.Error("buildToolsPrompt() should include function tools")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToolsPrompt_NoDescription(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
tools := []ToolDefinition{
|
||||
{Type: "function", Function: ToolFunctionDefinition{Name: "bare_tool"}},
|
||||
}
|
||||
got := p.buildToolsPrompt(tools)
|
||||
if !strings.Contains(got, "bare_tool") {
|
||||
t.Error("should include tool name")
|
||||
}
|
||||
if strings.Contains(got, "Description:") {
|
||||
t.Error("should not include Description: line when empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToolsPrompt_NoParameters(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
tools := []ToolDefinition{
|
||||
{Type: "function", Function: ToolFunctionDefinition{
|
||||
Name: "no_params_tool",
|
||||
Description: "A tool with no parameters",
|
||||
}},
|
||||
}
|
||||
got := p.buildToolsPrompt(tools)
|
||||
if strings.Contains(got, "Parameters:") {
|
||||
t.Error("should not include Parameters: section when nil")
|
||||
}
|
||||
}
|
||||
|
||||
// --- parseClaudeCliResponse tests ---
|
||||
|
||||
func TestParseClaudeCliResponse_TextOnly(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
output := `{"type":"result","subtype":"success","is_error":false,"result":"Hello, world!","session_id":"abc123","total_cost_usd":0.01,"duration_ms":500,"usage":{"input_tokens":10,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}`
|
||||
|
||||
resp, err := p.parseClaudeCliResponse(output)
|
||||
if err != nil {
|
||||
t.Fatalf("parseClaudeCliResponse() error = %v", err)
|
||||
}
|
||||
if resp.Content != "Hello, world!" {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Hello, world!")
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
if len(resp.ToolCalls) != 0 {
|
||||
t.Errorf("ToolCalls = %d, want 0", len(resp.ToolCalls))
|
||||
}
|
||||
if resp.Usage == nil {
|
||||
t.Fatal("Usage should not be nil")
|
||||
}
|
||||
if resp.Usage.PromptTokens != 10 {
|
||||
t.Errorf("PromptTokens = %d, want 10", resp.Usage.PromptTokens)
|
||||
}
|
||||
if resp.Usage.CompletionTokens != 20 {
|
||||
t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeCliResponse_EmptyResult(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
output := `{"type":"result","subtype":"success","is_error":false,"result":"","session_id":"abc"}`
|
||||
|
||||
resp, err := p.parseClaudeCliResponse(output)
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if resp.Content != "" {
|
||||
t.Errorf("Content = %q, want empty", resp.Content)
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeCliResponse_IsError(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
output := `{"type":"result","subtype":"error","is_error":true,"result":"Something went wrong","session_id":"abc"}`
|
||||
|
||||
_, err := p.parseClaudeCliResponse(output)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when is_error=true")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Something went wrong") {
|
||||
t.Errorf("error = %q, want to contain 'Something went wrong'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeCliResponse_NoUsage(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
output := `{"type":"result","subtype":"success","is_error":false,"result":"hi","session_id":"s"}`
|
||||
|
||||
resp, err := p.parseClaudeCliResponse(output)
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if resp.Usage != nil {
|
||||
t.Errorf("Usage should be nil when no tokens, got %+v", resp.Usage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeCliResponse_InvalidJSON(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
_, err := p.parseClaudeCliResponse("not json")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "failed to parse claude cli response") {
|
||||
t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeCliResponse_WithToolCalls(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
output := `{"type":"result","subtype":"success","is_error":false,"result":"Let me check.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"Tokyo\\\"}\"}}]}","session_id":"abc123","total_cost_usd":0.01}`
|
||||
|
||||
resp, err := p.parseClaudeCliResponse(output)
|
||||
if err != nil {
|
||||
t.Fatalf("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 = %d, want 1", len(resp.ToolCalls))
|
||||
}
|
||||
tc := resp.ToolCalls[0]
|
||||
if tc.Name != "get_weather" {
|
||||
t.Errorf("Name = %q, want %q", tc.Name, "get_weather")
|
||||
}
|
||||
if tc.Function == nil {
|
||||
t.Fatal("Function is nil")
|
||||
}
|
||||
if tc.Function.Name != "get_weather" {
|
||||
t.Errorf("Function.Name = %q, want %q", tc.Function.Name, "get_weather")
|
||||
}
|
||||
if tc.Arguments["location"] != "Tokyo" {
|
||||
t.Errorf("Arguments[location] = %v, want Tokyo", tc.Arguments["location"])
|
||||
}
|
||||
if strings.Contains(resp.Content, "tool_calls") {
|
||||
t.Errorf("Content should not contain tool_calls JSON, got %q", resp.Content)
|
||||
}
|
||||
if resp.Content != "Let me check." {
|
||||
t.Errorf("Content = %q, want %q", resp.Content, "Let me check.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseClaudeCliResponse_WhitespaceResult(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
output := `{"type":"result","subtype":"success","is_error":false,"result":" hello \n ","session_id":"s"}`
|
||||
|
||||
resp, err := p.parseClaudeCliResponse(output)
|
||||
if err != nil {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if resp.Content != "hello" {
|
||||
t.Errorf("Content = %q, want %q (should be trimmed)", resp.Content, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
// --- extractToolCalls tests ---
|
||||
|
||||
func TestExtractToolCalls_NoToolCalls(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
got := p.extractToolCalls("Just a regular response.")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("extractToolCalls() = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolCalls_WithToolCalls(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
text := `Here's the result:
|
||||
{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}`
|
||||
|
||||
got := p.extractToolCalls(text)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("extractToolCalls() = %d, want 1", len(got))
|
||||
}
|
||||
if got[0].ID != "call_1" {
|
||||
t.Errorf("ID = %q, want %q", got[0].ID, "call_1")
|
||||
}
|
||||
if got[0].Name != "test" {
|
||||
t.Errorf("Name = %q, want %q", got[0].Name, "test")
|
||||
}
|
||||
if got[0].Type != "function" {
|
||||
t.Errorf("Type = %q, want %q", got[0].Type, "function")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolCalls_InvalidJSON(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
got := p.extractToolCalls(`{"tool_calls":invalid}`)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("extractToolCalls() with invalid JSON = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolCalls_MultipleToolCalls(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
text := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"/tmp/out\",\"content\":\"hello\"}"}}]}`
|
||||
|
||||
got := p.extractToolCalls(text)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("extractToolCalls() = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Name != "read_file" {
|
||||
t.Errorf("[0].Name = %q, want %q", got[0].Name, "read_file")
|
||||
}
|
||||
if got[1].Name != "write_file" {
|
||||
t.Errorf("[1].Name = %q, want %q", got[1].Name, "write_file")
|
||||
}
|
||||
// Verify arguments were parsed
|
||||
if got[0].Arguments["path"] != "/tmp/test" {
|
||||
t.Errorf("[0].Arguments[path] = %v, want /tmp/test", got[0].Arguments["path"])
|
||||
}
|
||||
if got[1].Arguments["content"] != "hello" {
|
||||
t.Errorf("[1].Arguments[content] = %v, want hello", got[1].Arguments["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolCalls_UnmatchedBrace(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
got := p.extractToolCalls(`{"tool_calls":[{"id":"call_1"`)
|
||||
if len(got) != 0 {
|
||||
t.Errorf("extractToolCalls() with unmatched brace = %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{\"num\":42,\"flag\":true,\"name\":\"test\"}"}}]}`
|
||||
|
||||
got := p.extractToolCalls(text)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(got))
|
||||
}
|
||||
// Verify different argument types
|
||||
if got[0].Arguments["num"] != float64(42) {
|
||||
t.Errorf("Arguments[num] = %v (%T), want 42", got[0].Arguments["num"], got[0].Arguments["num"])
|
||||
}
|
||||
if got[0].Arguments["flag"] != true {
|
||||
t.Errorf("Arguments[flag] = %v, want true", got[0].Arguments["flag"])
|
||||
}
|
||||
if got[0].Arguments["name"] != "test" {
|
||||
t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"])
|
||||
}
|
||||
// Verify raw arguments string is preserved in FunctionCall
|
||||
if got[0].Function.Arguments == "" {
|
||||
t.Error("Function.Arguments should contain raw JSON string")
|
||||
}
|
||||
}
|
||||
|
||||
// --- stripToolCallsJSON tests ---
|
||||
|
||||
func TestStripToolCallsJSON(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
text := `Let me check the weather.
|
||||
{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}
|
||||
Done.`
|
||||
|
||||
got := p.stripToolCallsJSON(text)
|
||||
if strings.Contains(got, "tool_calls") {
|
||||
t.Errorf("should remove tool_calls JSON, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Let me check the weather.") {
|
||||
t.Errorf("should keep text before, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Done.") {
|
||||
t.Errorf("should keep text after, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripToolCallsJSON_NoToolCalls(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
text := "Just regular text."
|
||||
got := p.stripToolCallsJSON(text)
|
||||
if got != text {
|
||||
t.Errorf("stripToolCallsJSON() = %q, want %q", got, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripToolCallsJSON_OnlyToolCalls(t *testing.T) {
|
||||
p := NewClaudeCliProvider("/workspace")
|
||||
text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}}]}`
|
||||
got := p.stripToolCallsJSON(text)
|
||||
if got != "" {
|
||||
t.Errorf("stripToolCallsJSON() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- findMatchingBrace tests ---
|
||||
|
||||
func TestFindMatchingBrace(t *testing.T) {
|
||||
tests := []struct {
|
||||
text string
|
||||
pos int
|
||||
want int
|
||||
}{
|
||||
{`{"a":1}`, 0, 7},
|
||||
{`{"a":{"b":2}}`, 0, 13},
|
||||
{`text {"a":1} more`, 5, 12},
|
||||
{`{unclosed`, 0, 0}, // no match returns pos
|
||||
{`{}`, 0, 2}, // empty object
|
||||
{`{{{}}}`, 0, 6}, // deeply nested
|
||||
{`{"a":"b{c}d"}`, 0, 13}, // braces in strings (simplified matcher)
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := findMatchingBrace(tt.text, tt.pos)
|
||||
if got != tt.want {
|
||||
t.Errorf("findMatchingBrace(%q, %d) = %d, want %d", tt.text, tt.pos, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,8 +44,8 @@ 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})
|
||||
|
|
|
|||
79
pkg/providers/codex_cli_credentials.go
Normal file
79
pkg/providers/codex_cli_credentials.go
Normal file
|
|
@ -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
|
||||
}
|
||||
181
pkg/providers/codex_cli_credentials_test.go
Normal file
181
pkg/providers/codex_cli_credentials_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
251
pkg/providers/codex_cli_provider.go
Normal file
251
pkg/providers/codex_cli_provider.go
Normal file
|
|
@ -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
|
||||
}
|
||||
119
pkg/providers/codex_cli_provider_integration_test.go
Normal file
119
pkg/providers/codex_cli_provider_integration_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
585
pkg/providers/codex_cli_provider_test.go
Normal file
585
pkg/providers/codex_cli_provider_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package providers
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
|
|
@ -10,18 +11,27 @@ 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)
|
||||
enableWebSearch bool
|
||||
}
|
||||
|
||||
const defaultCodexInstructions = "You are Codex, a coding assistant."
|
||||
|
||||
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))
|
||||
|
|
@ -30,6 +40,7 @@ func NewCodexProvider(token, accountID string) *CodexProvider {
|
|||
return &CodexProvider{
|
||||
client: &client,
|
||||
accountID: accountID,
|
||||
enableWebSearch: true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -41,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 {
|
||||
|
|
@ -48,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
|
||||
|
||||
|
|
@ -101,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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -133,31 +257,61 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
|
|||
Input: responses.ResponseNewParamsInputUnion{
|
||||
OfInputItemList: inputItems,
|
||||
},
|
||||
Instructions: openai.Opt(instructions),
|
||||
Store: openai.Opt(false),
|
||||
}
|
||||
|
||||
if instructions != "" {
|
||||
params.Instructions = openai.Opt(instructions)
|
||||
} else {
|
||||
// ChatGPT Codex backend requires instructions to be present.
|
||||
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,
|
||||
|
|
@ -168,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
|
||||
}
|
||||
|
||||
|
|
@ -237,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package providers
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
|
@ -17,10 +18,20 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) {
|
|||
}
|
||||
params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{
|
||||
"max_tokens": 2048,
|
||||
})
|
||||
"temperature": 0.7,
|
||||
}, true)
|
||||
if params.Model != "gpt-4o" {
|
||||
t.Errorf("Model = %q, want %q", params.Model, "gpt-4o")
|
||||
}
|
||||
if !params.Instructions.Valid() {
|
||||
t.Fatal("Instructions should be set")
|
||||
}
|
||||
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) {
|
||||
|
|
@ -28,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")
|
||||
}
|
||||
|
|
@ -48,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")
|
||||
}
|
||||
|
|
@ -57,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{
|
||||
{
|
||||
|
|
@ -73,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))
|
||||
}
|
||||
|
|
@ -86,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",
|
||||
|
|
@ -197,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",
|
||||
|
|
@ -220,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()
|
||||
|
||||
|
|
@ -244,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -262,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")
|
||||
}
|
||||
|
|
|
|||
207
pkg/providers/cooldown.go
Normal file
207
pkg/providers/cooldown.go
Normal file
|
|
@ -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
|
||||
}
|
||||
269
pkg/providers/cooldown_test.go
Normal file
269
pkg/providers/cooldown_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
253
pkg/providers/error_classifier.go
Normal file
253
pkg/providers/error_classifier.go
Normal file
|
|
@ -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
|
||||
}
|
||||
337
pkg/providers/error_classifier_test.go
Normal file
337
pkg/providers/error_classifier_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
360
pkg/providers/factory.go
Normal file
360
pkg/providers/factory.go
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
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 createClaudeAuthProvider(apiBase string) (LLMProvider, error) {
|
||||
if apiBase == "" {
|
||||
apiBase = defaultAnthropicAPIBase
|
||||
}
|
||||
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 NewClaudeProviderWithTokenSourceAndBaseURL(cred.AccessToken, createClaudeTokenSource(), apiBase), nil
|
||||
}
|
||||
|
||||
func createCodexAuthProvider(enableWebSearch bool) (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")
|
||||
}
|
||||
p := NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource())
|
||||
p.enableWebSearch = enableWebSearch
|
||||
return p, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
||||
sel, err := resolveProviderSelection(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch sel.providerType {
|
||||
case providerTypeClaudeAuth:
|
||||
return createClaudeAuthProvider(sel.apiBase)
|
||||
case providerTypeCodexAuth:
|
||||
return createCodexAuthProvider(sel.enableWebSearch)
|
||||
case providerTypeCodexCLIToken:
|
||||
c := NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource())
|
||||
c.enableWebSearch = sel.enableWebSearch
|
||||
return c, nil
|
||||
case providerTypeClaudeCLI:
|
||||
return NewClaudeCliProvider(sel.workspace), nil
|
||||
case providerTypeCodexCLI:
|
||||
return NewCodexCliProvider(sel.workspace), nil
|
||||
case providerTypeGitHubCopilot:
|
||||
return NewGitHubCopilotProvider(sel.apiBase, sel.connectMode, sel.model)
|
||||
default:
|
||||
return NewHTTPProvider(sel.apiKey, sel.apiBase, sel.proxy), nil
|
||||
}
|
||||
}
|
||||
299
pkg/providers/factory_test.go
Normal file
299
pkg/providers/factory_test.go
Normal file
|
|
@ -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-5-20250929"
|
||||
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 = "openrouter/auto"
|
||||
cfg.Providers.OpenRouter.APIKey = "sk-or-test"
|
||||
|
||||
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.Provider = "codex-code"
|
||||
|
||||
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 TestCreateProviderReturnsCodexProviderForCodexCliAuthMethod(t *testing.T) {
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Provider = "openai"
|
||||
cfg.Providers.OpenAI.AuthMethod = "codex-cli"
|
||||
|
||||
provider, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider() error = %v", err)
|
||||
}
|
||||
|
||||
if _, ok := provider.(*CodexProvider); !ok {
|
||||
t.Fatalf("provider type = %T, want *CodexProvider", 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.Provider = "anthropic"
|
||||
cfg.Providers.Anthropic.AuthMethod = "oauth"
|
||||
cfg.Providers.Anthropic.APIBase = "https://proxy.example.com/v1"
|
||||
|
||||
provider, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider() error = %v", err)
|
||||
}
|
||||
|
||||
claudeProvider, ok := provider.(*ClaudeProvider)
|
||||
if !ok {
|
||||
t.Fatalf("provider type = %T, want *ClaudeProvider", provider)
|
||||
}
|
||||
if got := claudeProvider.delegate.BaseURL(); got != "https://proxy.example.com" {
|
||||
t.Fatalf("anthropic baseURL = %q, want %q", got, "https://proxy.example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProviderReturnsCodexProviderForOpenAIOAuth(t *testing.T) {
|
||||
originalGetCredential := getCredential
|
||||
t.Cleanup(func() { getCredential = originalGetCredential })
|
||||
|
||||
getCredential = func(provider string) (*auth.AuthCredential, error) {
|
||||
if provider != "openai" {
|
||||
t.Fatalf("provider = %q, want openai", provider)
|
||||
}
|
||||
return &auth.AuthCredential{
|
||||
AccessToken: "openai-token",
|
||||
AccountID: "acct_123",
|
||||
}, nil
|
||||
}
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
cfg.Agents.Defaults.Provider = "openai"
|
||||
cfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||
|
||||
provider, err := CreateProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProvider() error = %v", err)
|
||||
}
|
||||
|
||||
if _, ok := provider.(*CodexProvider); !ok {
|
||||
t.Fatalf("provider type = %T, want *CodexProvider", provider)
|
||||
}
|
||||
}
|
||||
283
pkg/providers/fallback.go
Normal file
283
pkg/providers/fallback.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
473
pkg/providers/fallback_test.go
Normal file
473
pkg/providers/fallback_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
82
pkg/providers/github_copilot_provider.go
Normal file
82
pkg/providers/github_copilot_provider.go
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
json "encoding/json"
|
||||
|
||||
copilot "github.com/github/copilot-sdk/go"
|
||||
)
|
||||
|
||||
type GitHubCopilotProvider struct {
|
||||
uri string
|
||||
connectMode string // `stdio` or `grpc``
|
||||
|
||||
session *copilot.Session
|
||||
}
|
||||
|
||||
func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
|
||||
|
||||
var session *copilot.Session
|
||||
if connectMode == "" {
|
||||
connectMode = "grpc"
|
||||
}
|
||||
switch connectMode {
|
||||
|
||||
case "stdio":
|
||||
//todo
|
||||
case "grpc":
|
||||
client := copilot.NewClient(&copilot.ClientOptions{
|
||||
CLIUrl: uri,
|
||||
})
|
||||
if err := client.Start(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details")
|
||||
}
|
||||
defer client.Stop()
|
||||
session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
|
||||
Model: model,
|
||||
Hooks: &copilot.SessionHooks{},
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
return &GitHubCopilotProvider{
|
||||
uri: uri,
|
||||
connectMode: connectMode,
|
||||
session: session,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Chat sends a chat request to GitHub Copilot
|
||||
func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
|
||||
type tempMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
out := make([]tempMessage, 0, len(messages))
|
||||
|
||||
for _, msg := range messages {
|
||||
out = append(out, tempMessage{
|
||||
Role: msg.Role,
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
fullcontent, _ := json.Marshal(out)
|
||||
|
||||
content, _ := p.session.Send(ctx, copilot.MessageOptions{
|
||||
Prompt: string(fullcontent),
|
||||
})
|
||||
|
||||
return &LLMResponse{
|
||||
FinishReason: "stop",
|
||||
Content: content,
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
func (p *GitHubCopilotProvider) GetDefaultModel() string {
|
||||
|
||||
return "gpt-4.1"
|
||||
}
|
||||
|
|
@ -7,272 +7,25 @@
|
|||
package providers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"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 string) *HTTPProvider {
|
||||
func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
||||
return &HTTPProvider{
|
||||
apiKey: apiKey,
|
||||
apiBase: apiBase,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 0,
|
||||
},
|
||||
delegate: openai_compat.NewProvider(apiKey, apiBase, proxy),
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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 {
|
||||
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 error: %s", 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
|
||||
|
||||
var apiKey, apiBase string
|
||||
|
||||
lowerModel := strings.ToLower(model)
|
||||
|
||||
switch {
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
if apiBase == "" {
|
||||
apiBase = "https://api.groq.com/openai/v1"
|
||||
}
|
||||
|
||||
case cfg.Providers.VLLM.APIBase != "":
|
||||
apiKey = cfg.Providers.VLLM.APIKey
|
||||
apiBase = cfg.Providers.VLLM.APIBase
|
||||
|
||||
default:
|
||||
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"
|
||||
}
|
||||
} 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), nil
|
||||
}
|
||||
64
pkg/providers/model_ref.go
Normal file
64
pkg/providers/model_ref.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
125
pkg/providers/model_ref_test.go
Normal file
125
pkg/providers/model_ref_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
232
pkg/providers/openai_compat/provider.go
Normal file
232
pkg/providers/openai_compat/provider.go
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
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 Provider struct {
|
||||
apiKey string
|
||||
apiBase string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewProvider(apiKey, apiBase, proxy 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, "/"),
|
||||
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 {
|
||||
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 := 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"`
|
||||
} `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 := ""
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
277
pkg/providers/openai_compat/provider_test.go
Normal file
277
pkg/providers/openai_compat/provider_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
45
pkg/providers/protocoltypes/types.go
Normal file
45
pkg/providers/protocoltypes/types.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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"`
|
||||
}
|
||||
|
||||
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 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"`
|
||||
}
|
||||
72
pkg/providers/tool_call_extract.go
Normal file
72
pkg/providers/tool_call_extract.go
Normal file
|
|
@ -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:])
|
||||
}
|
||||
|
|
@ -1,52 +1,64 @@
|
|||
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 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
|
||||
}
|
||||
|
|
|
|||
66
pkg/routing/agent_id.go
Normal file
66
pkg/routing/agent_id.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue