Migrate AI runtime to SDK and hardcode Zhipu coding endpoint

This commit is contained in:
2026-02-28 13:59:00 -05:00
parent abae5e7486
commit b9f3b7f9d0
14 changed files with 453 additions and 243 deletions

View File

@@ -12,21 +12,12 @@ BETTER_AUTH_SECRET=replace-with-a-long-random-secret
BETTER_AUTH_BASE_URL=https://fiscal.b11studio.xyz BETTER_AUTH_BASE_URL=https://fiscal.b11studio.xyz
BETTER_AUTH_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz BETTER_AUTH_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz
# OpenClaw / ZeroClaw (OpenAI-compatible) # AI SDK (Vercel) + Zhipu provider
# Leave empty to use internal `openclaw` Compose service (`http://openclaw:4000`). # Legacy OPENCLAW_* variables are removed and no longer read by the app.
# Set this only when targeting an external OpenClaw endpoint. # Coding endpoint is hardcoded in runtime: https://api.z.ai/api/coding/paas/v4
OPENCLAW_BASE_URL= ZHIPU_API_KEY=
OPENCLAW_API_KEY= ZHIPU_MODEL=glm-4.7-flashx
OPENCLAW_MODEL=zeroclaw AI_TEMPERATURE=0.2
OPENCLAW_AUTH_MODE=none
OPENCLAW_PORT=4000
# OpenClaw container source for Docker Compose
OPENCLAW_IMAGE=coolify-zeroclaw:local
# If this repo is private, include credentials in the URL:
# OPENCLAW_BUILD_CONTEXT=https://<username>:<token>@gitea-hs848cs8kgs840o8c8s8cwkk.b11studio.xyz/Francy51/coolify_ZeroClaw.git
OPENCLAW_BUILD_CONTEXT=https://gitea-hs848cs8kgs840o8c8s8cwkk.b11studio.xyz/Francy51/coolify_ZeroClaw.git
OPENCLAW_DOCKERFILE=Dockerfile
# SEC API etiquette # SEC API etiquette
SEC_USER_AGENT=Fiscal Clone <support@fiscal.local> SEC_USER_AGENT=Fiscal Clone <support@fiscal.local>

View File

@@ -1,6 +1,6 @@
# Fiscal Clone 3.0 # Fiscal Clone 3.0
Turbopack-first rebuild of a fiscal.ai-style terminal with OpenClaw integration. Turbopack-first rebuild of a fiscal.ai-style terminal with Vercel AI SDK integration.
## Stack ## Stack
@@ -14,7 +14,7 @@ Turbopack-first rebuild of a fiscal.ai-style terminal with OpenClaw integration.
- Eden Treaty for type-safe frontend API calls - Eden Treaty for type-safe frontend API calls
- Workflow DevKit Local World for background task execution - Workflow DevKit Local World for background task execution
- SQLite-backed domain storage (watchlist, holdings, filings, tasks, insights) - SQLite-backed domain storage (watchlist, holdings, filings, tasks, insights)
- OpenClaw/ZeroClaw analysis via OpenAI-compatible chat endpoint - Vercel AI SDK (`ai`) + Zhipu community provider (`zhipu-ai-provider`) for analysis tasks (hardcoded to `https://api.z.ai/api/coding/paas/v4`)
## Run locally ## Run locally
@@ -44,10 +44,8 @@ cp .env.example .env
docker compose up --build -d docker compose up --build -d
``` ```
For local Docker, host port mapping comes from `docker-compose.override.yml` (default `http://localhost:3000` via `APP_PORT`, and `http://localhost:4000` for OpenClaw via `OPENCLAW_PORT`). For local Docker, host port mapping comes from `docker-compose.override.yml` (default `http://localhost:3000` via `APP_PORT`).
OpenClaw is included as a Compose service (`openclaw`) and is built by default from `OPENCLAW_BUILD_CONTEXT` (set to `Francy51/coolify_ZeroClaw` in `.env.example`). The app calls Zhipu directly via AI SDK and always targets the Coding API endpoint (`https://api.z.ai/api/coding/paas/v4`), so no extra AI gateway container is required.
If that Gitea repo is private, set `OPENCLAW_BUILD_CONTEXT` with embedded credentials (`https://<username>:<token>@.../coolify_ZeroClaw.git`) or point `OPENCLAW_IMAGE` to a prebuilt image you can pull.
The app container defaults to `OPENCLAW_BASE_URL=http://openclaw:4000` unless you explicitly set a different `OPENCLAW_BASE_URL`.
On container startup, the app applies Drizzle migrations automatically before launching Next.js. On container startup, the app applies Drizzle migrations automatically before launching Next.js.
The app stores SQLite data in Docker volume `fiscal_sqlite_data` (mounted to `/app/data`) and workflow local data in `fiscal_workflow_data` (mounted to `/app/.workflow-data`). The app stores SQLite data in Docker volume `fiscal_sqlite_data` (mounted to `/app/data`) and workflow local data in `fiscal_workflow_data` (mounted to `/app/.workflow-data`).
@@ -88,19 +86,10 @@ BETTER_AUTH_SECRET=replace-with-a-long-random-secret
BETTER_AUTH_BASE_URL=https://fiscal.b11studio.xyz BETTER_AUTH_BASE_URL=https://fiscal.b11studio.xyz
BETTER_AUTH_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz BETTER_AUTH_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz
OPENCLAW_BASE_URL= ZHIPU_API_KEY=
OPENCLAW_API_KEY= ZHIPU_MODEL=glm-4.7-flashx
OPENCLAW_MODEL=zeroclaw # optional generation tuning
OPENCLAW_AUTH_MODE=none AI_TEMPERATURE=0.2
OPENCLAW_PORT=4000
OPENCLAW_IMAGE=coolify-zeroclaw:local
OPENCLAW_BUILD_CONTEXT=https://gitea-hs848cs8kgs840o8c8s8cwkk.b11studio.xyz/Francy51/coolify_ZeroClaw.git
OPENCLAW_DOCKERFILE=Dockerfile
# for OPENCLAW_AUTH_MODE=basic
# OPENCLAW_BASIC_AUTH_USERNAME=your_nginx_user
# OPENCLAW_BASIC_AUTH_PASSWORD=your_nginx_password
# optional: forward API key in a custom header when using basic/none auth
# OPENCLAW_API_KEY_HEADER=X-OpenClaw-API-Key
SEC_USER_AGENT=Fiscal Clone <support@fiscal.local> SEC_USER_AGENT=Fiscal Clone <support@fiscal.local>
WORKFLOW_TARGET_WORLD=local WORKFLOW_TARGET_WORLD=local
@@ -108,19 +97,27 @@ WORKFLOW_LOCAL_DATA_DIR=.workflow-data
WORKFLOW_LOCAL_QUEUE_CONCURRENCY=100 WORKFLOW_LOCAL_QUEUE_CONCURRENCY=100
``` ```
If OpenClaw is unset or invalidly configured, the app uses local fallback analysis so task workflows still run. If `ZHIPU_API_KEY` is unset, the app uses local fallback analysis so task workflows still run.
`ZHIPU_BASE_URL` is deprecated and ignored; runtime always uses `https://api.z.ai/api/coding/paas/v4`.
For OpenClaw behind Nginx Basic Auth, use: ## Migration from OPENCLAW_* to ZHIPU_*
```env This repository now uses direct provider calls through AI SDK. The legacy gateway container path is removed.
OPENCLAW_BASE_URL=https://your-nginx-host The AI runtime endpoint is fixed to `https://api.z.ai/api/coding/paas/v4`.
OPENCLAW_AUTH_MODE=basic
OPENCLAW_BASIC_AUTH_USERNAME=your_nginx_user | Legacy variable | Replacement |
OPENCLAW_BASIC_AUTH_PASSWORD=your_nginx_password | --- | --- |
# optional if upstream still needs an API key in a non-Authorization header | `OPENCLAW_API_KEY` | `ZHIPU_API_KEY` |
OPENCLAW_API_KEY=your_key | `OPENCLAW_MODEL` | `ZHIPU_MODEL` |
OPENCLAW_API_KEY_HEADER=X-OpenClaw-API-Key | `OPENCLAW_BASE_URL` | Removed (runtime endpoint is hardcoded to `https://api.z.ai/api/coding/paas/v4`) |
``` | `OPENCLAW_AUTH_MODE` | Removed (not needed with direct provider calls) |
| `OPENCLAW_BASIC_AUTH_USERNAME` | Removed |
| `OPENCLAW_BASIC_AUTH_PASSWORD` | Removed |
| `OPENCLAW_API_KEY_HEADER` | Removed |
| `OPENCLAW_PORT` | Removed (no gateway service) |
| `OPENCLAW_IMAGE` | Removed |
| `OPENCLAW_BUILD_CONTEXT` | Removed |
| `OPENCLAW_DOCKERFILE` | Removed |
## API surface ## API surface

View File

@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Fiscal Clone', title: 'Fiscal Clone',
description: 'Futuristic fiscal intelligence terminal with durable tasks and OpenClaw integration.' description: 'Futuristic fiscal intelligence terminal with durable tasks and AI SDK integration.'
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {

View File

@@ -184,7 +184,7 @@ export default function CommandCenterPage() {
)} )}
</Panel> </Panel>
<Panel title="AI Brief" subtitle="Latest portfolio insight from OpenClaw/ZeroClaw" className="xl:col-span-2"> <Panel title="AI Brief" subtitle="Latest portfolio insight from AI SDK (Zhipu)" className="xl:col-span-2">
{loading ? ( {loading ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading intelligence output...</p> <p className="text-sm text-[color:var(--terminal-muted)]">Loading intelligence output...</p>
) : state.latestInsight ? ( ) : state.latestInsight ? (

View File

@@ -8,6 +8,7 @@
"@elysiajs/eden": "^1.4.8", "@elysiajs/eden": "^1.4.8",
"@libsql/client": "^0.17.0", "@libsql/client": "^0.17.0",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"ai": "^6.0.104",
"better-auth": "^1.4.19", "better-auth": "^1.4.19",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -19,6 +20,7 @@
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"workflow": "^4.1.0-beta.60", "workflow": "^4.1.0-beta.60",
"zhipu-ai-provider": "^0.2.2",
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.3.0", "@types/node": "^25.3.0",
@@ -34,6 +36,12 @@
}, },
}, },
"packages": { "packages": {
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.58", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2e1hBCKsd+7m0hELwrakR1QDfZfFhz9PF2d4qb8TxQueEyApo7ydlEWRpXeKC+KdA2FRV21dMb1G6FxdeNDa2w=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
@@ -348,6 +356,8 @@
"@oclif/plugin-help": ["@oclif/plugin-help@6.2.31", "", { "dependencies": { "@oclif/core": "^4" } }, "sha512-o4xR98DEFf+VqY+M9B3ZooTm2T/mlGvyBHwHcnsPJCEnvzHqEA9xUlCUK4jm7FBXHhkppziMgCC2snsueLoIpQ=="], "@oclif/plugin-help": ["@oclif/plugin-help@6.2.31", "", { "dependencies": { "@oclif/core": "^4" } }, "sha512-o4xR98DEFf+VqY+M9B3ZooTm2T/mlGvyBHwHcnsPJCEnvzHqEA9xUlCUK4jm7FBXHhkppziMgCC2snsueLoIpQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@react-router/node": ["@react-router/node@7.13.0", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" }, "peerDependencies": { "react-router": "7.13.0", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-Mhr3fAou19oc/S93tKMIBHwCPfqLpWyWM/m0NWd3pJh/wZin8/9KhAdjwxhYbXw1TrTBZBLDENa35uZ+Y7oh3A=="], "@react-router/node": ["@react-router/node@7.13.0", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" }, "peerDependencies": { "react-router": "7.13.0", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-Mhr3fAou19oc/S93tKMIBHwCPfqLpWyWM/m0NWd3pJh/wZin8/9KhAdjwxhYbXw1TrTBZBLDENa35uZ+Y7oh3A=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" } }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" } }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
@@ -546,7 +556,7 @@
"@vercel/functions": ["@vercel/functions@3.4.3", "", { "dependencies": { "@vercel/oidc": "3.2.0" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity"] }, "sha512-kA14KIUVgAY6VXbhZ5jjY+s0883cV3cZqIU3WhrSRxuJ9KvxatMjtmzl0K23HK59oOUjYl7HaE/eYMmhmqpZzw=="], "@vercel/functions": ["@vercel/functions@3.4.3", "", { "dependencies": { "@vercel/oidc": "3.2.0" }, "peerDependencies": { "@aws-sdk/credential-provider-web-identity": "*" }, "optionalPeers": ["@aws-sdk/credential-provider-web-identity"] }, "sha512-kA14KIUVgAY6VXbhZ5jjY+s0883cV3cZqIU3WhrSRxuJ9KvxatMjtmzl0K23HK59oOUjYl7HaE/eYMmhmqpZzw=="],
"@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vercel/queue": ["@vercel/queue@0.0.0-alpha.38", "", { "dependencies": { "@vercel/oidc": "^3.0.5", "mixpart": "0.0.5-alpha.1" } }, "sha512-gSYpZrYy1LpzfXqDMUZX7xEIQxyhelvMDgthxijs495kHIxWC65S0C3vaAw5+3c1ujbPeJgLz8fn3SDTJspssw=="], "@vercel/queue": ["@vercel/queue@0.0.0-alpha.38", "", { "dependencies": { "@vercel/oidc": "^3.0.5", "mixpart": "0.0.5-alpha.1" } }, "sha512-gSYpZrYy1LpzfXqDMUZX7xEIQxyhelvMDgthxijs495kHIxWC65S0C3vaAw5+3c1ujbPeJgLz8fn3SDTJspssw=="],
@@ -614,6 +624,8 @@
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"ai": ["ai@6.0.104", "", { "dependencies": { "@ai-sdk/gateway": "3.0.58", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-boYGxbtdsa1YX3uuN7BV0FvAL3sGq7p/RLAMonK94jyt5C7sKj6jfib3/wD12koqX53htLTI/l4tX0HqNFRMZQ=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
"ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
@@ -864,6 +876,8 @@
"events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
@@ -1030,6 +1044,8 @@
"json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
@@ -1468,6 +1484,8 @@
"yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="],
"zhipu-ai-provider": ["zhipu-ai-provider@0.2.2", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.0" } }, "sha512-UjX1ho4DI9ICUv/mrpAnzmrRe5/LXrGkS5hF6h4WDY2aup5GketWWopFzWYCqsbArXAM5wbzzdH9QzZusgGiBg=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.3", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-tma6D8/xHZHJEUqmr6ksZjZ0onyIUqKDQLyp50ttZJmS0IwFYzxBgp5CxFvpYAnah52V3UtgrqGA6E83gtT7NQ=="], "@aws-crypto/sha256-browser/@aws-sdk/types": ["@aws-sdk/types@3.973.3", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-tma6D8/xHZHJEUqmr6ksZjZ0onyIUqKDQLyp50ttZJmS0IwFYzxBgp5CxFvpYAnah52V3UtgrqGA6E83gtT7NQ=="],
@@ -1680,6 +1698,8 @@
"@workflow/world-local/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "@workflow/world-local/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@workflow/world-vercel/@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
"@workflow/world-vercel/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="], "@workflow/world-vercel/zod": ["zod@4.1.11", "", {}, "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg=="],
"@xhmikosr/archive-type/file-type": ["file-type@20.5.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.6", "strtok3": "^10.2.0", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg=="], "@xhmikosr/archive-type/file-type": ["file-type@20.5.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.6", "strtok3": "^10.2.0", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg=="],
@@ -1772,6 +1792,10 @@
"wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], "wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
"zhipu-ai-provider/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="],
"zhipu-ai-provider/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@aws-crypto/sha256-browser/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], "@aws-crypto/sha256-browser/@aws-sdk/types/@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],

View File

@@ -18,7 +18,7 @@ export function AuthShell({ title, subtitle, children, footer }: AuthShellProps)
<p className="terminal-caption text-xs uppercase tracking-[0.3em] text-[color:var(--terminal-muted)]">Fiscal Clone</p> <p className="terminal-caption text-xs uppercase tracking-[0.3em] text-[color:var(--terminal-muted)]">Fiscal Clone</p>
<h1 className="mt-3 text-3xl font-semibold text-[color:var(--terminal-bright)]">Autonomous Analyst Desk</h1> <h1 className="mt-3 text-3xl font-semibold text-[color:var(--terminal-bright)]">Autonomous Analyst Desk</h1>
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-muted)]"> <p className="mt-3 text-sm leading-6 text-[color:var(--terminal-muted)]">
Secure entry into filings intelligence, portfolio diagnostics, and async AI workflows connected to OpenClaw/ZeroClaw. Secure entry into filings intelligence, portfolio diagnostics, and async AI workflows powered by AI SDK.
</p> </p>
<Link <Link
href="https://www.sec.gov/" href="https://www.sec.gov/"

View File

@@ -94,7 +94,7 @@ export function AppShell({ title, subtitle, actions, children }: AppShellProps)
<p className="mt-1 truncate text-sm text-[color:var(--terminal-bright)]">{displayName}</p> <p className="mt-1 truncate text-sm text-[color:var(--terminal-bright)]">{displayName}</p>
{role ? <p className="mt-1 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Role: {role}</p> : null} {role ? <p className="mt-1 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Role: {role}</p> : null}
<p className="mt-2 text-xs text-[color:var(--terminal-muted)]"> <p className="mt-2 text-xs text-[color:var(--terminal-muted)]">
OpenClaw and market data are driven by environment configuration and live API tasks. AI and market data are driven by environment configuration and live API tasks.
</p> </p>
<Button className="mt-3 w-full" variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}> <Button className="mt-3 w-full" variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}>
<LogOut className="size-4" /> <LogOut className="size-4" />

View File

@@ -5,6 +5,3 @@ services:
environment: environment:
BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-http://localhost:3000} BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-http://localhost:3000}
BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:3000} BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-http://localhost:3000}
openclaw:
ports:
- '${OPENCLAW_PORT:-4000}:4000'

View File

@@ -18,13 +18,9 @@ services:
BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-https://fiscal.b11studio.xyz} BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-https://fiscal.b11studio.xyz}
BETTER_AUTH_ADMIN_USER_IDS: ${BETTER_AUTH_ADMIN_USER_IDS:-} BETTER_AUTH_ADMIN_USER_IDS: ${BETTER_AUTH_ADMIN_USER_IDS:-}
BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-https://fiscal.b11studio.xyz} BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-https://fiscal.b11studio.xyz}
OPENCLAW_BASE_URL: ${OPENCLAW_BASE_URL:-http://openclaw:4000} ZHIPU_API_KEY: ${ZHIPU_API_KEY:-}
OPENCLAW_API_KEY: ${OPENCLAW_API_KEY:-} ZHIPU_MODEL: ${ZHIPU_MODEL:-glm-4.7-flashx}
OPENCLAW_MODEL: ${OPENCLAW_MODEL:-zeroclaw} AI_TEMPERATURE: ${AI_TEMPERATURE:-0.2}
OPENCLAW_AUTH_MODE: ${OPENCLAW_AUTH_MODE:-none}
OPENCLAW_BASIC_AUTH_USERNAME: ${OPENCLAW_BASIC_AUTH_USERNAME:-}
OPENCLAW_BASIC_AUTH_PASSWORD: ${OPENCLAW_BASIC_AUTH_PASSWORD:-}
OPENCLAW_API_KEY_HEADER: ${OPENCLAW_API_KEY_HEADER:-}
SEC_USER_AGENT: ${SEC_USER_AGENT:-Fiscal Clone <support@fiscal.local>} SEC_USER_AGENT: ${SEC_USER_AGENT:-Fiscal Clone <support@fiscal.local>}
WORKFLOW_TARGET_WORLD: local WORKFLOW_TARGET_WORLD: local
WORKFLOW_LOCAL_DATA_DIR: ${WORKFLOW_LOCAL_DATA_DIR:-/app/.workflow-data} WORKFLOW_LOCAL_DATA_DIR: ${WORKFLOW_LOCAL_DATA_DIR:-/app/.workflow-data}
@@ -32,9 +28,6 @@ services:
volumes: volumes:
- fiscal_sqlite_data:/app/data - fiscal_sqlite_data:/app/data
- fiscal_workflow_data:/app/.workflow-data - fiscal_workflow_data:/app/.workflow-data
depends_on:
openclaw:
condition: service_started
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/api/health || exit 1"] test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/api/health || exit 1"]
interval: 30s interval: 30s
@@ -43,15 +36,6 @@ services:
expose: expose:
- "3000" - "3000"
openclaw:
image: ${OPENCLAW_IMAGE:-coolify-zeroclaw:local}
build:
context: ${OPENCLAW_BUILD_CONTEXT:-https://gitea-hs848cs8kgs840o8c8s8cwkk.b11studio.xyz/Francy51/coolify_ZeroClaw.git}
dockerfile: ${OPENCLAW_DOCKERFILE:-Dockerfile}
restart: unless-stopped
expose:
- "4000"
volumes: volumes:
fiscal_sqlite_data: fiscal_sqlite_data:
fiscal_workflow_data: fiscal_workflow_data:

192
lib/server/ai.test.ts Normal file
View File

@@ -0,0 +1,192 @@
import { beforeEach, describe, expect, it, mock } from 'bun:test';
import {
__resetAiWarningsForTests,
getAiConfig,
runAiAnalysis
} from './ai';
type EnvSource = Record<string, string | undefined>;
const CODING_API_BASE_URL = 'https://api.z.ai/api/coding/paas/v4';
describe('ai config and runtime', () => {
beforeEach(() => {
__resetAiWarningsForTests();
});
it('uses coding endpoint defaults when optional env values are missing', () => {
const config = getAiConfig({
env: {
ZHIPU_API_KEY: 'key'
},
warn: () => {}
});
expect(config.apiKey).toBe('key');
expect(config.baseUrl).toBe(CODING_API_BASE_URL);
expect(config.model).toBe('glm-4.7-flashx');
expect(config.temperature).toBe(0.2);
});
it('ignores ZHIPU_BASE_URL and keeps the hardcoded coding endpoint', () => {
const config = getAiConfig({
env: {
ZHIPU_API_KEY: 'key',
ZHIPU_BASE_URL: 'https://api.z.ai/api/paas/v4'
},
warn: () => {}
});
expect(config.baseUrl).toBe(CODING_API_BASE_URL);
});
it('clamps temperature into [0, 2]', () => {
const negative = getAiConfig({
env: {
ZHIPU_API_KEY: 'key',
AI_TEMPERATURE: '-2'
},
warn: () => {}
});
expect(negative.temperature).toBe(0);
const high = getAiConfig({
env: {
ZHIPU_API_KEY: 'key',
AI_TEMPERATURE: '9'
},
warn: () => {}
});
expect(high.temperature).toBe(2);
const invalid = getAiConfig({
env: {
ZHIPU_API_KEY: 'key',
AI_TEMPERATURE: 'not-a-number'
},
warn: () => {}
});
expect(invalid.temperature).toBe(0.2);
});
it('returns fallback output when ZHIPU_API_KEY is missing', async () => {
const generate = mock(async () => ({ text: 'should-not-be-used' }));
const result = await runAiAnalysis(
'Prompt line one\nPrompt line two',
'System prompt',
{
env: {},
warn: () => {},
generate
}
);
expect(result.provider).toBe('local-fallback');
expect(result.model).toBe('glm-4.7-flashx');
expect(result.text).toContain('AI SDK fallback mode is active');
expect(generate).not.toHaveBeenCalled();
});
it('warns once when deprecated OPENCLAW_* env vars are present', () => {
const warn = mock((_message: string) => {});
const env: EnvSource = {
OPENCLAW_API_KEY: 'legacy-key',
OPENCLAW_BASE_URL: 'http://legacy.local',
ZHIPU_API_KEY: 'new-key'
};
getAiConfig({ env, warn });
getAiConfig({ env, warn });
expect(warn).toHaveBeenCalledTimes(1);
});
it('warns once when ZHIPU_BASE_URL is set because coding endpoint is hardcoded', () => {
const warn = mock((_message: string) => {});
const env: EnvSource = {
ZHIPU_API_KEY: 'new-key',
ZHIPU_BASE_URL: 'https://api.z.ai/api/paas/v4'
};
getAiConfig({ env, warn });
getAiConfig({ env, warn });
expect(warn).toHaveBeenCalledTimes(1);
});
it('does not consume OPENCLAW_* values for live generation', async () => {
const generate = mock(async () => ({ text: 'should-not-be-used' }));
const warn = mock((_message: string) => {});
const result = await runAiAnalysis('Legacy-only env prompt', undefined, {
env: {
OPENCLAW_API_KEY: 'legacy-key',
OPENCLAW_MODEL: 'legacy-model'
},
warn,
generate
});
expect(result.provider).toBe('local-fallback');
expect(result.model).toBe('glm-4.7-flashx');
expect(generate).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledTimes(1);
});
it('uses configured ZHIPU values and injected generator when API key exists', async () => {
const createModel = mock((config: {
apiKey?: string;
model: string;
baseUrl: string;
temperature: number;
}) => {
expect(config.apiKey).toBe('new-key');
expect(config.baseUrl).toBe(CODING_API_BASE_URL);
expect(config.model).toBe('glm-4-plus');
expect(config.temperature).toBe(0.4);
return { modelId: config.model };
});
const generate = mock(async (input: {
model: unknown;
system?: string;
prompt: string;
temperature: number;
}) => {
expect(input.system).toBe('Use concise style');
expect(input.prompt).toBe('Analyze this filing');
expect(input.temperature).toBe(0.4);
return { text: ' Generated insight ' };
});
const result = await runAiAnalysis('Analyze this filing', 'Use concise style', {
env: {
ZHIPU_API_KEY: 'new-key',
ZHIPU_MODEL: 'glm-4-plus',
ZHIPU_BASE_URL: 'https://api.z.ai/api/paas/v4',
AI_TEMPERATURE: '0.4'
},
warn: () => {},
createModel,
generate
});
expect(createModel).toHaveBeenCalledTimes(1);
expect(generate).toHaveBeenCalledTimes(1);
expect(result.provider).toBe('zhipu');
expect(result.model).toBe('glm-4-plus');
expect(result.text).toBe('Generated insight');
});
it('throws when AI generation returns an empty response', async () => {
await expect(
runAiAnalysis('Analyze this filing', undefined, {
env: { ZHIPU_API_KEY: 'new-key' },
warn: () => {},
createModel: () => ({}),
generate: async () => ({ text: ' ' })
})
).rejects.toThrow('AI SDK returned an empty response');
});
});

190
lib/server/ai.ts Normal file
View File

@@ -0,0 +1,190 @@
import { generateText } from 'ai';
import { createZhipu } from 'zhipu-ai-provider';
type AiConfig = {
apiKey?: string;
baseUrl: string;
model: string;
temperature: number;
};
type EnvSource = Record<string, string | undefined>;
type GetAiConfigOptions = {
env?: EnvSource;
warn?: (message: string) => void;
};
type AiGenerateInput = {
model: unknown;
system?: string;
prompt: string;
temperature: number;
};
type AiGenerateOutput = {
text: string;
};
type RunAiAnalysisOptions = GetAiConfigOptions & {
createModel?: (config: AiConfig) => unknown;
generate?: (input: AiGenerateInput) => Promise<AiGenerateOutput>;
};
const DEPRECATED_LEGACY_GATEWAY_ENV_KEYS = [
'OPENCLAW_BASE_URL',
'OPENCLAW_API_KEY',
'OPENCLAW_MODEL',
'OPENCLAW_AUTH_MODE',
'OPENCLAW_BASIC_AUTH_USERNAME',
'OPENCLAW_BASIC_AUTH_PASSWORD',
'OPENCLAW_API_KEY_HEADER',
'OPENCLAW_PORT',
'OPENCLAW_IMAGE',
'OPENCLAW_BUILD_CONTEXT',
'OPENCLAW_DOCKERFILE'
] as const;
const CODING_API_BASE_URL = 'https://api.z.ai/api/coding/paas/v4';
let warnedDeprecatedGatewayEnv = false;
let warnedIgnoredZhipuBaseUrl = false;
function envValue(name: string, env: EnvSource = process.env) {
const value = env[name];
if (!value) {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function parseTemperature(value: string | undefined) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
return 0.2;
}
return Math.min(Math.max(parsed, 0), 2);
}
function warnDeprecatedGatewayEnv(env: EnvSource, warn: (message: string) => void) {
if (warnedDeprecatedGatewayEnv) {
return;
}
const presentKeys = DEPRECATED_LEGACY_GATEWAY_ENV_KEYS.filter((key) => Boolean(envValue(key, env)));
if (presentKeys.length === 0) {
return;
}
warnedDeprecatedGatewayEnv = true;
warn(
`[AI SDK] Deprecated OPENCLAW_* variables are ignored after migration: ${presentKeys.join(', ')}. Use ZHIPU_API_KEY, ZHIPU_MODEL, and AI_TEMPERATURE.`
);
}
function warnIgnoredZhipuBaseUrl(env: EnvSource, warn: (message: string) => void) {
if (warnedIgnoredZhipuBaseUrl) {
return;
}
const configuredBaseUrl = envValue('ZHIPU_BASE_URL', env);
if (!configuredBaseUrl) {
return;
}
warnedIgnoredZhipuBaseUrl = true;
warn(
`[AI SDK] ZHIPU_BASE_URL is ignored. The Coding API endpoint is hardcoded to ${CODING_API_BASE_URL}.`
);
}
function fallbackResponse(prompt: string) {
const clipped = prompt.split('\n').slice(0, 6).join(' ').slice(0, 260);
return [
'AI SDK fallback mode is active (Zhipu configuration is missing).',
'Thesis: Portfolio remains analyzable with local heuristics until live model access is configured.',
'Risk scan: Concentration and filing sentiment should be monitored after each sync cycle.',
`Context digest: ${clipped}`
].join('\n\n');
}
function defaultCreateModel(config: AiConfig) {
const zhipu = createZhipu({
apiKey: config.apiKey,
baseURL: config.baseUrl
});
return zhipu(config.model);
}
async function defaultGenerate(input: AiGenerateInput): Promise<AiGenerateOutput> {
const result = await generateText({
model: input.model as never,
system: input.system,
prompt: input.prompt,
temperature: input.temperature
});
return { text: result.text };
}
export function getAiConfig(options?: GetAiConfigOptions) {
const env = options?.env ?? process.env;
warnDeprecatedGatewayEnv(env, options?.warn ?? console.warn);
warnIgnoredZhipuBaseUrl(env, options?.warn ?? console.warn);
return {
apiKey: envValue('ZHIPU_API_KEY', env),
baseUrl: CODING_API_BASE_URL,
model: envValue('ZHIPU_MODEL', env) ?? 'glm-4.7-flashx',
temperature: parseTemperature(envValue('AI_TEMPERATURE', env))
} satisfies AiConfig;
}
export function isAiConfigured(options?: GetAiConfigOptions) {
const config = getAiConfig(options);
return Boolean(config.apiKey);
}
export async function runAiAnalysis(prompt: string, systemPrompt?: string, options?: RunAiAnalysisOptions) {
const config = getAiConfig(options);
if (!config.apiKey) {
return {
provider: 'local-fallback',
model: config.model,
text: fallbackResponse(prompt)
};
}
const createModel = options?.createModel ?? defaultCreateModel;
const generate = options?.generate ?? defaultGenerate;
const model = createModel(config);
const result = await generate({
model,
system: systemPrompt,
prompt,
temperature: config.temperature
});
const text = result.text.trim();
if (!text) {
throw new Error('AI SDK returned an empty response');
}
return {
provider: 'zhipu',
model: config.model,
text
};
}
export function __resetAiWarningsForTests() {
warnedDeprecatedGatewayEnv = false;
warnedIgnoredZhipuBaseUrl = false;
}

View File

@@ -1,167 +0,0 @@
type ChatCompletionResponse = {
choices?: Array<{
message?: {
content?: string;
};
}>;
};
type OpenClawAuthMode = 'bearer' | 'basic' | 'none';
type OpenClawConfig = {
baseUrl?: string;
apiKey?: string;
model: string;
authMode: OpenClawAuthMode;
basicAuthUsername?: string;
basicAuthPassword?: string;
apiKeyHeader?: string;
};
function envValue(name: string) {
const value = process.env[name];
if (!value) {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
const DEFAULT_MODEL = 'zeroclaw';
const DEFAULT_AUTH_MODE: OpenClawAuthMode = 'bearer';
function parseAuthMode(value: string | undefined): OpenClawAuthMode {
const normalized = value?.trim().toLowerCase();
if (normalized === 'basic' || normalized === 'none') {
return normalized;
}
return DEFAULT_AUTH_MODE;
}
function hasSupportedProtocol(url: string) {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
}
function buildCompletionsUrl(baseUrl: string) {
if (!hasSupportedProtocol(baseUrl)) {
return undefined;
}
const withSlash = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
return new URL('v1/chat/completions', withSlash).toString();
}
function hasRequiredAuth(config: OpenClawConfig) {
if (config.authMode === 'none') {
return true;
}
if (config.authMode === 'basic') {
return Boolean(config.basicAuthUsername && config.basicAuthPassword);
}
return Boolean(config.apiKey);
}
function buildAuthHeaders(config: OpenClawConfig) {
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
if (config.authMode === 'basic' && config.basicAuthUsername && config.basicAuthPassword) {
const credentials = Buffer
.from(`${config.basicAuthUsername}:${config.basicAuthPassword}`, 'utf8')
.toString('base64');
headers.Authorization = `Basic ${credentials}`;
} else if (config.authMode === 'bearer' && config.apiKey) {
headers.Authorization = `Bearer ${config.apiKey}`;
}
if (config.apiKey && config.apiKeyHeader) {
headers[config.apiKeyHeader] = config.apiKey;
}
return headers;
}
function fallbackResponse(prompt: string) {
const clipped = prompt.split('\n').slice(0, 6).join(' ').slice(0, 260);
return [
'OpenClaw fallback mode is active (configuration is missing or invalid).',
'Thesis: Portfolio remains analyzable with local heuristics until live model access is configured.',
'Risk scan: Concentration and filing sentiment should be monitored after each sync cycle.',
`Context digest: ${clipped}`
].join('\n\n');
}
export function getOpenClawConfig() {
return {
baseUrl: envValue('OPENCLAW_BASE_URL'),
apiKey: envValue('OPENCLAW_API_KEY'),
model: envValue('OPENCLAW_MODEL') ?? DEFAULT_MODEL,
authMode: parseAuthMode(envValue('OPENCLAW_AUTH_MODE')),
basicAuthUsername: envValue('OPENCLAW_BASIC_AUTH_USERNAME'),
basicAuthPassword: envValue('OPENCLAW_BASIC_AUTH_PASSWORD'),
apiKeyHeader: envValue('OPENCLAW_API_KEY_HEADER')
} satisfies OpenClawConfig;
}
export function isOpenClawConfigured() {
const config = getOpenClawConfig();
return Boolean(config.baseUrl && hasSupportedProtocol(config.baseUrl) && hasRequiredAuth(config));
}
export async function runOpenClawAnalysis(prompt: string, systemPrompt?: string) {
const config = getOpenClawConfig();
const endpoint = config.baseUrl ? buildCompletionsUrl(config.baseUrl) : undefined;
if (!endpoint || !hasRequiredAuth(config)) {
return {
provider: 'local-fallback',
model: config.model,
text: fallbackResponse(prompt)
};
}
const response = await fetch(endpoint, {
method: 'POST',
headers: buildAuthHeaders(config),
body: JSON.stringify({
model: config.model,
temperature: 0.2,
messages: [
systemPrompt
? { role: 'system', content: systemPrompt }
: null,
{ role: 'user', content: prompt }
].filter(Boolean)
}),
cache: 'no-store'
});
if (!response.ok) {
const body = await response.text();
throw new Error(`OpenClaw request failed (${response.status}): ${body.slice(0, 220)}`);
}
const payload = await response.json() as ChatCompletionResponse;
const text = payload.choices?.[0]?.message?.content?.trim();
if (!text) {
throw new Error('OpenClaw returned an empty response');
}
return {
provider: 'openclaw',
model: config.model,
text
};
}

View File

@@ -1,5 +1,5 @@
import type { Filing, Holding, Task } from '@/lib/types'; import type { Filing, Holding, Task } from '@/lib/types';
import { runOpenClawAnalysis } from '@/lib/server/openclaw'; import { runAiAnalysis } from '@/lib/server/ai';
import { buildPortfolioSummary } from '@/lib/server/portfolio'; import { buildPortfolioSummary } from '@/lib/server/portfolio';
import { getQuote } from '@/lib/server/prices'; import { getQuote } from '@/lib/server/prices';
import { import {
@@ -143,7 +143,7 @@ async function processAnalyzeFiling(task: Task) {
'Return concise sections: Thesis, Red Flags, Follow-up Questions, Portfolio Impact.' 'Return concise sections: Thesis, Red Flags, Follow-up Questions, Portfolio Impact.'
].join('\n'); ].join('\n');
const analysis = await runOpenClawAnalysis(prompt, 'Use concise institutional analyst language.'); const analysis = await runAiAnalysis(prompt, 'Use concise institutional analyst language.');
await saveFilingAnalysis(accessionNumber, { await saveFilingAnalysis(accessionNumber, {
provider: analysis.provider, provider: analysis.provider,
@@ -186,7 +186,7 @@ async function processPortfolioInsights(task: Task) {
'Respond with: 1) health score (0-100), 2) top 3 risks, 3) top 3 opportunities, 4) next actions in 7 days.' 'Respond with: 1) health score (0-100), 2) top 3 risks, 3) top 3 opportunities, 4) next actions in 7 days.'
].join('\n'); ].join('\n');
const analysis = await runOpenClawAnalysis(prompt, 'Act as a risk-aware buy-side analyst.'); const analysis = await runAiAnalysis(prompt, 'Act as a risk-aware buy-side analyst.');
await createPortfolioInsight({ await createPortfolioInsight({
userId, userId,

View File

@@ -15,6 +15,7 @@
"@elysiajs/eden": "^1.4.8", "@elysiajs/eden": "^1.4.8",
"@libsql/client": "^0.17.0", "@libsql/client": "^0.17.0",
"@tailwindcss/postcss": "^4.2.1", "@tailwindcss/postcss": "^4.2.1",
"ai": "^6.0.104",
"better-auth": "^1.4.19", "better-auth": "^1.4.19",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@@ -25,7 +26,8 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"workflow": "^4.1.0-beta.60" "workflow": "^4.1.0-beta.60",
"zhipu-ai-provider": "^0.2.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.3.0", "@types/node": "^25.3.0",