diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..b68a28b7e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,117 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What is ValueCell + +ValueCell is a community-driven, multi-agent platform for financial applications. It combines a Python FastAPI backend with a React/Tauri frontend to deliver AI-powered stock research, trading strategy automation, and portfolio management via a multi-agent orchestration system. + +## Development Commands + +### Backend (Python) + +```bash +# Install dependencies +uv sync + +# Run backend in dev mode +uv run python -m valuecell.server.main + +# Tests +uv run pytest ./python # All tests +uv run pytest ./python/tests/test_foo.py # Single file +uv run pytest -k "test_name" # Single test by name + +# Lint & format +make lint # ruff check +make format # ruff format + isort +``` + +### Frontend + +```bash +cd frontend +bun install +bun run dev # Dev server (port 1420) +bun run build # Production build +bun run typecheck # Type check (react-router typegen + tsc) +bun run lint # Biome lint +bun run lint:fix # Biome auto-fix +bun run format # Biome format +``` + +### Docker (full stack) + +```bash +docker compose up -d --build # Start everything +docker compose logs -f # Follow logs +``` + +### Quick start (full dev environment) + +```bash +bash start.sh # Linux/macOS — installs tools, syncs deps, starts both servers +``` + +## Architecture Overview + +### Backend Layers + +``` +FastAPI (server/) + └── Orchestrator (core/coordinate/orchestrator.py) + ├── Super Agent (core/super_agent/) — triage: answer OR hand off to Planner + ├── Planner (core/plan/planner.py) — converts intent → ExecutionPlan; triggers HITL + ├── Task Executor (core/task/executor.py) — runs plan tasks via A2A protocol + └── Event Router (core/event/) — maps A2A events → typed responses → UI stream + └── Conversation Store (core/conversation/) — SQLite persistence +Agents (agents/) + ├── research_agent/ — SEC EDGAR-based company analysis + ├── prompt_strategy_agent/ — LLM-driven trading strategies + ├── grid_agent/ — grid trading automation + └── news_agent/ — news retrieval & scheduled delivery +Adapters (adapters/) + ├── Yahoo Finance, AKShare, BaoStock — market data + ├── CCXT — 40+ exchange integrations + └── EDGAR — SEC filing retrieval +Storage + ├── SQLite (aiosqlite/SQLAlchemy async) — conversations, tasks, watchlists + └── LanceDB — vector embeddings +``` + +### Orchestration Flow + +1. **Super Agent** — fast triage; either answers directly or enriches the query and hands off to Planner +2. **Planner** — produces a typed `ExecutionPlan`; detects missing params; blocks for Human-in-the-Loop (HITL) approval/clarification when needed +3. **Task Executor** — executes plan tasks asynchronously via Agent2Agent (A2A) protocol +4. **Event Router** — translates `TaskStatusUpdateEvent` → `BaseResponse` subtypes, annotates with stable `item_id`, streams to UI and persists + +### Frontend Architecture + +- **Framework**: React 19 + React Router 7 + Vite (rolldown) +- **Desktop**: Tauri 2 (cross-platform app wrapper) +- **State**: Zustand stores; TanStack React Query for server sync +- **Forms**: TanStack React Form + Zod +- **UI**: Radix UI headless components + Tailwind CSS 4 + shadcn +- **Charts**: ECharts + TradingView integration +- **i18n**: i18next (en, zh_CN, zh_TW, ja) +- Key entry points: `frontend/src/root.tsx` (routing), `frontend/src/app/agent/chat.tsx` (main chat UI) + +### Configuration System (3-tier priority) + +1. Environment variables (highest) +2. `.env` file +3. `python/configs/*.yaml` files (defaults: `config.yaml`, `providers/`, `agents/`, `agent_cards/`) + +Copy `.env.example` to `.env` and set at least one LLM provider key (e.g. `OPENROUTER_API_KEY`). + +## Code Conventions (from AGENTS.md) + +- **Async-first**: all I/O must be async — use `httpx`, SQLAlchemy async, `anyio` +- **Type hints**: required on all public and internal APIs; prefer Pydantic models over `dict` +- **Imports**: avoid inline imports; use qualified imports for 3+ names from one module +- **Logging**: `loguru` with `{}` placeholders — `logger.info` for key events, `logger.warning` for recoverable errors, `logger.exception` only for unexpected errors +- **Error handling**: catch specific exceptions; max 2 nesting levels +- **Function size**: keep under 200 lines, max 10 parameters (prefer structs) +- **Runtime checks**: prefer Pydantic validation over `getattr`/`hasattr` +- **Python version**: 3.12+; package manager: `uv`; virtual env at `./python/.venv` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..9a4c232f6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +# ============================================================================= +# Valuecell — Docker Compose +# +# Services: +# backend - Python FastAPI (uvicorn), port 8000 +# frontend - React/Vite (nginx), port 3200 +# +# Usage: +# docker compose up -d --build +# docker compose logs -f +# ============================================================================= + +services: + + # --------------------------------------------------------------------------- + # Backend — Python FastAPI + # --------------------------------------------------------------------------- + backend: + build: + context: ./python + dockerfile: ../docker/DockerFile + image: valuecell-backend:latest + container_name: valuecell_backend + restart: unless-stopped + env_file: + - ./python/.env + environment: + # Skip stdin control thread — stdin is closed in Docker non-interactive mode + # which would cause immediate shutdown via control_loop EOF handler + ENV: local_dev + volumes: + - ./python/configs:/app/configs + - valuecell_userdata:/root/.config/valuecell + ports: + - "8000:8000" + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/system/health')\" || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + # --------------------------------------------------------------------------- + # Frontend — React/Vite (served via Nginx) + # --------------------------------------------------------------------------- + frontend: + build: + context: . + dockerfile: ./docker/Dockerfile.frontend + args: + VITE_API_BASE_URL: "http://10.11.2.150:8000/api/v1" + image: valuecell-frontend:latest + container_name: valuecell_frontend + restart: unless-stopped + ports: + - "3200:80" + depends_on: + backend: + condition: service_healthy + +volumes: + valuecell_userdata: diff --git a/docker/DockerFile b/docker/DockerFile index a854e391b..e1543170c 100644 --- a/docker/DockerFile +++ b/docker/DockerFile @@ -21,4 +21,4 @@ RUN --mount=type=cache,target=/root/.cache/uv \ EXPOSE 8000 # Run the application. -CMD ["uv", "run", "python", "main.py", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uv", "run", "python", "-m", "valuecell.server.main"] diff --git a/docker/Dockerfile.frontend b/docker/Dockerfile.frontend new file mode 100644 index 000000000..6b0fd9ac0 --- /dev/null +++ b/docker/Dockerfile.frontend @@ -0,0 +1,23 @@ +# ============================================================================= +# Valuecell Frontend — Pre-built Static Serve +# +# Frontend is pre-built on host (bun run build) due to CPU AVX requirement. +# This Dockerfile copies the build output and serves it with Nginx. +# +# To rebuild frontend: +# cd /root/.openclaw/apps/valuecell/frontend +# VITE_API_BASE_URL=http://10.11.2.150:8000/api/v1 bun run build +# docker compose build frontend +# ============================================================================= + +FROM nginx:alpine + +# Copy pre-built frontend assets +COPY frontend/build/client /usr/share/nginx/html + +# Copy nginx config +COPY docker/nginx.frontend.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/nginx.frontend.conf b/docker/nginx.frontend.conf new file mode 100644 index 000000000..21ae68ef1 --- /dev/null +++ b/docker/nginx.frontend.conf @@ -0,0 +1,22 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # SPA routing — fallback to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 000000000..35fb511cc --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,28 @@ +module.exports = { + apps: [ + { + name: 'valuecell-backend', + script: 'uv', + args: 'run python -m valuecell.server.main', + cwd: '/root/.openclaw/apps/valuecell/python', + env: { + NODE_ENV: 'production', + HOST: '0.0.0.0', + PORT: '8000', + }, + interpreter: 'none', + }, + { + name: 'valuecell-frontend', + script: 'bun', + args: 'run start', + cwd: '/root/.openclaw/apps/valuecell/frontend', + env: { + NODE_ENV: 'production', + PORT: '3200', + HOST: '0.0.0.0', + }, + interpreter: 'none', + } + ] +}; diff --git a/frontend/src/api/setting.ts b/frontend/src/api/setting.ts index fc179de5a..2574729a4 100644 --- a/frontend/src/api/setting.ts +++ b/frontend/src/api/setting.ts @@ -70,12 +70,14 @@ export const useUpdateProviderConfig = () => { provider: string; api_key?: string; base_url?: string; + auth_token?: string; }) => apiClient.put>( `/models/providers/${params.provider}/config`, { api_key: params.api_key, base_url: params.base_url, + auth_token: params.auth_token, }, ), onSuccess: (_data, variables) => { diff --git a/frontend/src/app/setting/components/models/model-detail.tsx b/frontend/src/app/setting/components/models/model-detail.tsx index 4a9d38cc2..e2a86ee21 100644 --- a/frontend/src/app/setting/components/models/model-detail.tsx +++ b/frontend/src/app/setting/components/models/model-detail.tsx @@ -41,6 +41,7 @@ import LinkButton from "@/components/valuecell/button/link-button"; const configSchema = z.object({ api_key: z.string(), base_url: z.string(), + auth_token: z.string().optional(), }); const addModelSchema = z.object({ @@ -76,10 +77,13 @@ export function ModelDetail({ provider }: ModelDetailProps) { const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [showApiKey, setShowApiKey] = useState(false); + const isAnthropicProvider = provider === "anthropic"; + const configForm = useForm({ defaultValues: { api_key: "", base_url: "", + auth_token: "", }, validators: { onSubmit: configSchema, @@ -88,8 +92,9 @@ export function ModelDetail({ provider }: ModelDetailProps) { if (!provider) return; updateConfig({ provider, - api_key: value.api_key, + api_key: isAnthropicProvider ? "" : value.api_key, base_url: value.base_url, + ...(isAnthropicProvider && { auth_token: value.auth_token }), }); }, }); @@ -179,124 +184,231 @@ export function ModelDetail({ provider }: ModelDetailProps) {
- - {(field) => ( - - - {t("settings.models.apiKey")} - -
- - field.handleChange(e.target.value)} - onBlur={() => configForm.handleSubmit()} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - e.currentTarget.blur(); - } + {isAnthropicProvider ? ( + /* Anthropic OAuth Token field */ + + {(field) => ( + + + OAuth Token + +
+ + field.handleChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + configForm.handleSubmit(); + } + }} + /> + + setShowApiKey(!showApiKey)} + aria-label={ + showApiKey + ? t("settings.models.hidePassword") + : t("settings.models.showPassword") + } + > + {showApiKey ? ( + + ) : ( + + )} + + + + + +
+ {checkResult?.data && ( +
+ {checkResult.data.ok ? ( + + {t("settings.models.available")} + + ) : ( + + {t("settings.models.unavailable")} + {checkResult.data.error + ? `: ${checkResult.data.error}` + : ""} + + )} +
+ )} + {providerDetail.auth_token_set && !field.state.value && ( +

+ ✓ Token already saved. Enter a new token to replace it. +

+ )} +

+ OpenClaw / Claude Code OAuth token. Set via{" "} + ANTHROPIC_AUTH_TOKEN{" "} + environment variable. +

+ +
+ )} +
+ ) : ( + /* Standard API Key field for other providers */ + + {(field) => ( + + - {checkingAvailability - ? t("settings.models.waitingForCheck") - : t("settings.models.checkAvailability")} - -
- {checkResult?.data && ( -
- {checkResult.data.ok ? ( - - {t("settings.models.available")} - {checkResult.data.status - ? ` (${checkResult.data.status})` - : ""} - - ) : ( - - {t("settings.models.unavailable")} - {checkResult.data.status - ? ` (${checkResult.data.status})` - : ""} - {checkResult.data.error - ? `: ${checkResult.data.error}` - : ""} - - )} + {t("settings.models.apiKey")} + +
+ + field.handleChange(e.target.value)} + onBlur={() => configForm.handleSubmit()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.currentTarget.blur(); + } + }} + /> + + setShowApiKey(!showApiKey)} + aria-label={ + showApiKey + ? t("settings.models.hidePassword") + : t("settings.models.showPassword") + } + > + {showApiKey ? ( + + ) : ( + + )} + + + + +
- )} - - {t("settings.models.getApiKey")} - - - - )} - + {checkResult?.data && ( +
+ {checkResult.data.ok ? ( + + {t("settings.models.available")} + {checkResult.data.status + ? ` (${checkResult.data.status})` + : ""} + + ) : ( + + {t("settings.models.unavailable")} + {checkResult.data.status + ? ` (${checkResult.data.status})` + : ""} + {checkResult.data.error + ? `: ${checkResult.data.error}` + : ""} + + )} +
+ )} + + {t("settings.models.getApiKey")} + + + + )} + + )} - {/* API Host section */} - - {(field) => ( - - - {t("settings.models.apiHost")} - - field.handleChange(e.target.value)} - onBlur={() => configForm.handleSubmit()} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - e.currentTarget.blur(); - } - }} - /> - - - )} - + {/* API Host section — hidden for Anthropic */} + {!isAnthropicProvider && ( + + {(field) => ( + + + {t("settings.models.apiHost")} + + field.handleChange(e.target.value)} + onBlur={() => configForm.handleSubmit()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.currentTarget.blur(); + } + }} + /> + + + )} + + )} {/* Models section */} diff --git a/frontend/src/assets/png/index.ts b/frontend/src/assets/png/index.ts index 4aaea61e5..09fc604c1 100644 --- a/frontend/src/assets/png/index.ts +++ b/frontend/src/assets/png/index.ts @@ -33,6 +33,7 @@ export { default as IconGroupPng } from "./icon-group.png"; export { default as IconGroupDarkPng } from "./icon-group-dark.png"; export { default as MessageGroupPng } from "./message-group.png"; export { default as MessageGroupDarkPng } from "./message-group-dark.png"; +export { default as AnthropicPng } from "./model-providers/anthropic.png"; export { default as AzurePng } from "./model-providers/azure.png"; export { default as DashScopePng } from "./model-providers/dashscope.png"; export { default as DeepSeekPng } from "./model-providers/deepseek.png"; diff --git a/frontend/src/assets/png/model-providers/anthropic.png b/frontend/src/assets/png/model-providers/anthropic.png new file mode 100644 index 000000000..456369e4d Binary files /dev/null and b/frontend/src/assets/png/model-providers/anthropic.png differ diff --git a/frontend/src/components/valuecell/form/ai-model-form.tsx b/frontend/src/components/valuecell/form/ai-model-form.tsx index 1ac510b51..27fe52404 100644 --- a/frontend/src/components/valuecell/form/ai-model-form.tsx +++ b/frontend/src/components/valuecell/form/ai-model-form.tsx @@ -33,6 +33,8 @@ export const AIModelForm = withForm({ refetch: fetchModelProviderDetail, } = useGetModelProviderDetail(provider); + const isAnthropic = provider === "anthropic"; + // Set the default provider once loaded and provider is not yet selected useEffect(() => { if (isLoadingProviders || !defaultProvider) return; @@ -100,14 +102,29 @@ export const AIModelForm = withForm({ }} - - {(field) => ( - - )} - + {/* Hide API key field for Anthropic — uses OAuth token from environment */} + {!isAnthropic && ( + + {(field) => ( + + )} + + )} + + {/* Anthropic: show info about OAuth token */} + {isAnthropic && ( +
+

OAuth Token

+

+ Anthropic uses an OAuth token ( + ANTHROPIC_AUTH_TOKEN) configured + in the server environment. No API key needed here. +

+
+ )} ); }, diff --git a/frontend/src/constants/icons.ts b/frontend/src/constants/icons.ts index 25fe155bb..e9f9c1876 100644 --- a/frontend/src/constants/icons.ts +++ b/frontend/src/constants/icons.ts @@ -1,4 +1,5 @@ import { + AnthropicPng, AzurePng, BinancePng, BlockchainPng, @@ -19,6 +20,7 @@ import { } from "@/assets/png"; export const MODEL_PROVIDER_ICONS = { + anthropic: AnthropicPng, openrouter: OpenRouterPng, siliconflow: SiliconFlowPng, openai: OpenAiPng, diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3eaf87c75..7d70daf1d 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -176,7 +176,9 @@ "openai": "OpenAI", "openai-compatible": "OpenAI Compatible API", "openrouter": "OpenRouter", - "siliconflow": "SiliconFlow" + "siliconflow": "SiliconFlow", + "anthropic": "Anthropic", + "ollama": "Ollama" }, "title": "Trading Strategies", "add": "Add trading strategy", @@ -431,4 +433,4 @@ } } } -} +} \ No newline at end of file diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index b09f36495..afa701962 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -176,7 +176,9 @@ "openai": "OpenAI", "openai-compatible": "OpenAI互換API", "openrouter": "OpenRouter", - "siliconflow": "SiliconFlow" + "siliconflow": "SiliconFlow", + "anthropic": "Anthropic", + "ollama": "Ollama" }, "title": "取引戦略", "add": "取引戦略を追加", @@ -431,4 +433,4 @@ } } } -} +} \ No newline at end of file diff --git a/frontend/src/i18n/locales/zh_CN.json b/frontend/src/i18n/locales/zh_CN.json index 36b478ab8..108645165 100644 --- a/frontend/src/i18n/locales/zh_CN.json +++ b/frontend/src/i18n/locales/zh_CN.json @@ -176,7 +176,9 @@ "openai": "OpenAI", "openai-compatible": "OpenAI兼容API", "openrouter": "OpenRouter", - "siliconflow": "硅基流动" + "siliconflow": "硅基流动", + "anthropic": "Anthropic", + "ollama": "Ollama" }, "title": "交易策略", "add": "添加交易策略", @@ -431,4 +433,4 @@ } } } -} +} \ No newline at end of file diff --git a/frontend/src/i18n/locales/zh_TW.json b/frontend/src/i18n/locales/zh_TW.json index 32247aba6..5427041cf 100644 --- a/frontend/src/i18n/locales/zh_TW.json +++ b/frontend/src/i18n/locales/zh_TW.json @@ -176,7 +176,9 @@ "openai": "OpenAI", "openai-compatible": "OpenAI相容API", "openrouter": "OpenRouter", - "siliconflow": "SiliconFlow" + "siliconflow": "SiliconFlow", + "anthropic": "Anthropic", + "ollama": "Ollama" }, "title": "交易策略", "add": "新增交易策略", @@ -431,4 +433,4 @@ } } } -} +} \ No newline at end of file diff --git a/python/configs/providers/openrouter.yaml b/python/configs/providers/openrouter.yaml index 27e76d08f..2c6498d29 100644 --- a/python/configs/providers/openrouter.yaml +++ b/python/configs/providers/openrouter.yaml @@ -1,64 +1,38 @@ -# ============================================ -# OpenRouter Provider Configuration -# ============================================ -name: "OpenRouter" -provider_type: "openrouter" - -enabled: true # Default is true if not specified - -# Connection Configuration +name: OpenRouter +provider_type: openrouter +enabled: true connection: - base_url: "https://openrouter.ai/api/v1" - api_key_env: "OPENROUTER_API_KEY" - -# Default model if none specified -default_model: "qwen/qwen3-max" - -# Model Parameters Defaults + base_url: https://openrouter.ai/api/v1 + api_key_env: OPENROUTER_API_KEY +default_model: qwen/qwen3-max defaults: temperature: 0.5 - -# Extra headers for OpenRouter API extra_headers: - HTTP-Referer: "https://valuecell.ai" - X-Title: "ValueCell" - -# Available Models (commonly used) + HTTP-Referer: https://valuecell.ai + X-Title: ValueCell models: - - id: "anthropic/claude-haiku-4.5" - name: "Claude Haiku 4.5" - - id: "x-ai/grok-4" - name: "Grok 4" - - id: "qwen/qwen3-max" - name: "Qwen3 Max" - - id: "openai/gpt-5" - name: "GPT-5" - - id: "google/gemini-2.5-flash" - name: "Gemini 2.5 Flash" - - id: "google/gemini-3-pro-preview" - name: "Gemini 3 Pro Preview" - - id: "x-ai/grok-4.1-fast" - name: "Grok 4.1 Fast" - -# ============================================ -# Embedding Models Configuration -# ============================================ -# OpenRouter provides embedding models +- id: anthropic/claude-haiku-4.5 + name: Claude Haiku 4.5 +- id: x-ai/grok-4 + name: Grok 4 +- id: qwen/qwen3-max + name: Qwen3 Max +- id: openai/gpt-5 + name: GPT-5 +- id: google/gemini-2.5-flash + name: Gemini 2.5 Flash +- id: google/gemini-3-pro-preview + name: Gemini 3 Pro Preview +- id: x-ai/grok-4.1-fast + name: Grok 4.1 Fast embedding: - # Default embedding model - default_model: "qwen/qwen3-embedding-4b" - - # Default parameters + default_model: qwen/qwen3-embedding-4b defaults: dimensions: 2560 - encoding_format: "float" - - # Available embedding models + encoding_format: float models: - - id: "qwen/qwen3-embedding-4b" - name: "Qwen3 Embedding 4B" + - id: qwen/qwen3-embedding-4b + name: Qwen3 Embedding 4B dimensions: 2560 max_input: 32768 - description: "Qwen3 Embedding Model" - - + description: Qwen3 Embedding Model diff --git a/python/pyproject.toml b/python/pyproject.toml index 3a754b452..b67016ee4 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -19,7 +19,8 @@ dependencies = [ "yfinance>=0.2.65", "requests>=2.32.5", "akshare>=1.17.87", - "agno[openai, google, lancedb, ollama]>=2.0,<3.0", + "agno[openai, google, lancedb, ollama, anthropic]>=2.0,<3.0", + "anthropic>=0.40.0", "edgartools>=4.12.2", "sqlalchemy>=2.0.43", "aiosqlite>=0.19.0", diff --git a/python/uv.lock b/python/uv.lock index ff3460773..29fb35a88 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.14'", @@ -55,6 +55,9 @@ wheels = [ ] [package.optional-dependencies] +anthropic = [ + { name = "anthropic" }, +] google = [ { name = "google-genai" }, ] @@ -240,6 +243,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "anthropic" +version = "0.87.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/8f/3281edf7c35cbac169810e5388eb9b38678c7ea9867c2d331237bd5dff08/anthropic-0.87.0.tar.gz", hash = "sha256:098fef3753cdd3c0daa86f95efb9c8d03a798d45c5170329525bb4653f6702d0", size = 588982, upload-time = "2026-03-31T17:52:41.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/02/99bf351933bdea0545a2b6e2d812ed878899e9a95f618351dfa3d0de0e69/anthropic-0.87.0-py3-none-any.whl", hash = "sha256:e2669b86d42c739d3df163f873c51719552e263a3d85179297180fb4fa00a236", size = 472126, upload-time = "2026-03-31T17:52:40.174Z" }, +] + [[package]] name = "anyio" version = "4.10.0" @@ -3667,10 +3689,11 @@ version = "0.1.20" source = { editable = "." } dependencies = [ { name = "a2a-sdk", extra = ["http-server"] }, - { name = "agno", extra = ["google", "lancedb", "ollama", "openai"] }, + { name = "agno", extra = ["anthropic", "google", "lancedb", "ollama", "openai"] }, { name = "aiofiles" }, { name = "aiosqlite" }, { name = "akshare" }, + { name = "anthropic" }, { name = "baostock" }, { name = "ccxt" }, { name = "crawl4ai" }, @@ -3726,10 +3749,11 @@ test = [ [package.metadata] requires-dist = [ { name = "a2a-sdk", extras = ["http-server"], specifier = ">=0.3.4" }, - { name = "agno", extras = ["openai", "google", "lancedb", "ollama"], specifier = ">=2.0,<3.0" }, + { name = "agno", extras = ["openai", "google", "lancedb", "ollama", "anthropic"], specifier = ">=2.0,<3.0" }, { name = "aiofiles", specifier = ">=24.1.0" }, { name = "aiosqlite", specifier = ">=0.19.0" }, { name = "akshare", specifier = ">=1.17.87" }, + { name = "anthropic", specifier = ">=0.40.0" }, { name = "baostock", specifier = ">=0.8.9" }, { name = "ccxt", specifier = ">=4.5.15" }, { name = "crawl4ai", specifier = ">=0.7.4" }, diff --git a/python/valuecell/adapters/models/factory.py b/python/valuecell/adapters/models/factory.py index 341b90525..72ef48326 100644 --- a/python/valuecell/adapters/models/factory.py +++ b/python/valuecell/adapters/models/factory.py @@ -564,6 +564,67 @@ def create_embedder(self, model_id: Optional[str] = None, **kwargs): ) +# Claude Code OAuth headers — required for OAuth token to work +_CLAUDE_CODE_HEADERS = { + "accept": "application/json", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-beta": "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14", + "user-agent": "claude-code/2.1.75", + "x-app": "cli", +} + + +class AnthropicProvider(ModelProvider): + """Anthropic model provider — supports both API key and OAuth token (sk-ant-oat01-...). + + OAuth tokens from OpenClaw/Claude Code require special headers to work. + These headers identify the request as coming from Claude Code CLI. + """ + + def create_model(self, model_id: Optional[str] = None, **kwargs): + """Create Anthropic Claude model via agno""" + try: + from agno.models.anthropic import Claude + except ImportError: + raise ImportError( + "anthropic package not installed. Install with: pip install anthropic" + ) + + import os + import anthropic as anthropic_sdk + + model_id = model_id or self.config.default_model + params = {**self.config.parameters, **kwargs} + + # Determine auth: prefer OAuth token (ANTHROPIC_AUTH_TOKEN) over API key + auth_token = os.getenv("ANTHROPIC_AUTH_TOKEN") + api_key = self.config.api_key or os.getenv("ANTHROPIC_API_KEY") + + if auth_token: + logger.info(f"Creating Anthropic Claude model: {model_id} (auth: oauth_token)") + # OAuth token requires Claude Code headers to work + client = anthropic_sdk.Anthropic( + api_key=None, + auth_token=auth_token, + default_headers=_CLAUDE_CODE_HEADERS, + ) + else: + logger.info(f"Creating Anthropic Claude model: {model_id} (auth: api_key)") + client = anthropic_sdk.Anthropic(api_key=api_key) + + return Claude( + id=model_id, + client=client, + temperature=params.get("temperature"), + max_tokens=params.get("max_tokens"), + ) + + def is_available(self) -> bool: + """Check if Anthropic credentials available (API key or OAuth token)""" + import os + return bool(self.config.api_key or os.getenv("ANTHROPIC_AUTH_TOKEN")) + + class OllamaProvider(ModelProvider): """Ollama model provider""" @@ -608,6 +669,7 @@ class ModelFactory: "openai-compatible": OpenAICompatibleProvider, "deepseek": DeepSeekProvider, "dashscope": DashScopeProvider, + "anthropic": AnthropicProvider, "ollama": OllamaProvider, } diff --git a/python/valuecell/config/manager.py b/python/valuecell/config/manager.py index 5da57fd19..b39b76cd4 100644 --- a/python/valuecell/config/manager.py +++ b/python/valuecell/config/manager.py @@ -396,13 +396,19 @@ def validate_provider(self, provider_name: str) -> tuple[bool, Optional[str]]: # Check API key (except for ollama) if provider_name != "ollama" and not provider_config.api_key: - # Get the env var name for helpful error message + # Some providers support OAuth / auth tokens instead of API keys (e.g. Anthropic). + # Check the provider YAML for an auth_token_env and see if it is set. provider_data = self.loader.load_provider_config(provider_name) - api_key_env = provider_data.get("connection", {}).get("api_key_env") - return ( - False, - f"API key not found for '{provider_name}'. Please set {api_key_env} in .env", - ) + auth_token_env = provider_data.get("connection", {}).get("auth_token_env") + if auth_token_env and os.getenv(auth_token_env): + # Auth token present — skip API key requirement + pass + else: + api_key_env = provider_data.get("connection", {}).get("api_key_env") + return ( + False, + f"API key not found for '{provider_name}'. Please set {api_key_env} in .env", + ) # Azure needs endpoint too if provider_name == "azure" and not provider_config.base_url: diff --git a/python/valuecell/server/api/routers/models.py b/python/valuecell/server/api/routers/models.py index ec7797f6b..906daa09c 100644 --- a/python/valuecell/server/api/routers/models.py +++ b/python/valuecell/server/api/routers/models.py @@ -124,6 +124,13 @@ def _preferred_provider_order(names: List[str]) -> List[str]: return ordered + def _auth_token_set_for(provider: str) -> bool: + """Return True if an OAuth/auth token is configured for the given provider.""" + loader = get_config_loader() + provider_raw = loader.load_provider_config(provider) + auth_token_env = provider_raw.get("connection", {}).get("auth_token_env") if provider_raw else None + return bool(auth_token_env and os.environ.get(auth_token_env)) + def _api_key_url_for(provider: str) -> str | None: """Return the URL for obtaining an API key for the given provider.""" mapping = { @@ -197,6 +204,7 @@ async def get_provider_detail(provider: str) -> SuccessResponse[ProviderDetailDa is_default=(cfg.name == manager.primary_provider), default_model_id=cfg.default_model, api_key_url=_api_key_url_for(cfg.name), + auth_token_set=_auth_token_set_for(cfg.name), models=models_entries, ) return SuccessResponse.create( @@ -228,6 +236,13 @@ async def update_provider_config( api_key_env = connection.get("api_key_env") endpoint_env = connection.get("endpoint_env") + # Update OAuth token via env var (e.g. Anthropic) + # Only update when a non-empty token is provided; empty string is ignored + # so that a page reload (which resets the field to "") does not wipe a saved token. + auth_token_env = connection.get("auth_token_env") + if auth_token_env and payload.auth_token: + _set_env(auth_token_env, payload.auth_token) + # Update API key via env var # Accept empty string as a deliberate clear; skip only when field is omitted if api_key_env and (payload.api_key is not None): @@ -284,6 +299,7 @@ async def update_provider_config( base_url=cfg.base_url, is_default=(cfg.name == manager.primary_provider), default_model_id=cfg.default_model, + auth_token_set=_auth_token_set_for(cfg.name), models=models_items, ) return SuccessResponse.create( @@ -472,6 +488,7 @@ async def set_provider_default_model( base_url=cfg.base_url, is_default=(cfg.name == manager.primary_provider), default_model_id=cfg.default_model, + auth_token_set=_auth_token_set_for(cfg.name), models=models_items, ) return SuccessResponse.create( @@ -531,6 +548,47 @@ async def check_model( result.error = f"Runtime dependency missing: {e}" return SuccessResponse.create(data=result, msg="Live check failed") + # Anthropic: check via SDK directly using OAuth token + if provider == "anthropic": + auth_token = os.environ.get("ANTHROPIC_AUTH_TOKEN", "").strip() + if not auth_token: + result.ok = False + result.status = "auth_missing" + result.error = "ANTHROPIC_AUTH_TOKEN not set in environment" + return SuccessResponse.create(data=result, msg="Auth missing") + try: + import anthropic as _anthropic + _client = _anthropic.Anthropic( + api_key=None, + auth_token=auth_token, + default_headers={ + "accept": "application/json", + "anthropic-dangerous-direct-browser-access": "true", + "anthropic-beta": "claude-code-20250219,oauth-2025-04-20", + "user-agent": "claude-code/2.1.75", + "x-app": "cli", + }, + ) + _client.messages.create( + model=model_id, + max_tokens=1, + messages=[{"role": "user", "content": "ping"}], + ) + result.ok = True + result.status = "reachable" + return SuccessResponse.create(data=result, msg="Model reachable") + except Exception as _e: + err_str = str(_e) + # 429 rate_limit means the token is valid and was accepted by Anthropic + if "429" in err_str or "rate_limit" in err_str.lower(): + result.ok = True + result.status = "reachable" + return SuccessResponse.create(data=result, msg="Model reachable") + result.ok = False + result.status = "request_failed" + result.error = err_str[:200] + return SuccessResponse.create(data=result, msg="Check failed") + # Prefer a direct minimal request for OpenAI-compatible providers. # This avoids hidden fallbacks and validates API key/auth. api_key = (payload.api_key or cfg.api_key or "").strip() diff --git a/python/valuecell/server/api/schemas/model.py b/python/valuecell/server/api/schemas/model.py index 6a72b426b..8e9f507f7 100644 --- a/python/valuecell/server/api/schemas/model.py +++ b/python/valuecell/server/api/schemas/model.py @@ -51,6 +51,9 @@ class ProviderDetailData(BaseModel): api_key_url: Optional[str] = Field( None, description="URL to obtain/apply for the provider's API key" ) + auth_token_set: bool = Field( + False, description="Whether an OAuth/auth token is currently configured" + ) models: List[ProviderModelEntry] = Field( default_factory=list, description="Available provider models" ) @@ -61,6 +64,9 @@ class ProviderUpdateRequest(BaseModel): base_url: Optional[str] = Field( None, description="New API base URL to set for provider" ) + auth_token: Optional[str] = Field( + None, description="OAuth token (e.g. Anthropic Claude Code token)" + ) class AddModelRequest(BaseModel):