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_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz
# OpenClaw / ZeroClaw (OpenAI-compatible)
# Leave empty to use internal `openclaw` Compose service (`http://openclaw:4000`).
# Set this only when targeting an external OpenClaw endpoint.
OPENCLAW_BASE_URL=
OPENCLAW_API_KEY=
OPENCLAW_MODEL=zeroclaw
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
# AI SDK (Vercel) + Zhipu provider
# Legacy OPENCLAW_* variables are removed and no longer read by the app.
# Coding endpoint is hardcoded in runtime: https://api.z.ai/api/coding/paas/v4
ZHIPU_API_KEY=
ZHIPU_MODEL=glm-4.7-flashx
AI_TEMPERATURE=0.2
# SEC API etiquette
SEC_USER_AGENT=Fiscal Clone <support@fiscal.local>

View File

@@ -1,6 +1,6 @@
# 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
@@ -14,7 +14,7 @@ Turbopack-first rebuild of a fiscal.ai-style terminal with OpenClaw integration.
- Eden Treaty for type-safe frontend API calls
- Workflow DevKit Local World for background task execution
- 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
@@ -44,10 +44,8 @@ cp .env.example .env
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`).
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`).
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`.
For local Docker, host port mapping comes from `docker-compose.override.yml` (default `http://localhost:3000` via `APP_PORT`).
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.
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`).
@@ -88,19 +86,10 @@ BETTER_AUTH_SECRET=replace-with-a-long-random-secret
BETTER_AUTH_BASE_URL=https://fiscal.b11studio.xyz
BETTER_AUTH_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz
OPENCLAW_BASE_URL=
OPENCLAW_API_KEY=
OPENCLAW_MODEL=zeroclaw
OPENCLAW_AUTH_MODE=none
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
ZHIPU_API_KEY=
ZHIPU_MODEL=glm-4.7-flashx
# optional generation tuning
AI_TEMPERATURE=0.2
SEC_USER_AGENT=Fiscal Clone <support@fiscal.local>
WORKFLOW_TARGET_WORLD=local
@@ -108,19 +97,27 @@ WORKFLOW_LOCAL_DATA_DIR=.workflow-data
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
OPENCLAW_BASE_URL=https://your-nginx-host
OPENCLAW_AUTH_MODE=basic
OPENCLAW_BASIC_AUTH_USERNAME=your_nginx_user
OPENCLAW_BASIC_AUTH_PASSWORD=your_nginx_password
# optional if upstream still needs an API key in a non-Authorization header
OPENCLAW_API_KEY=your_key
OPENCLAW_API_KEY_HEADER=X-OpenClaw-API-Key
```
This repository now uses direct provider calls through AI SDK. The legacy gateway container path is removed.
The AI runtime endpoint is fixed to `https://api.z.ai/api/coding/paas/v4`.
| Legacy variable | Replacement |
| --- | --- |
| `OPENCLAW_API_KEY` | `ZHIPU_API_KEY` |
| `OPENCLAW_MODEL` | `ZHIPU_MODEL` |
| `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

View File

@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
export const metadata: Metadata = {
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 }) {

View File

@@ -184,7 +184,7 @@ export default function CommandCenterPage() {
)}
</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 ? (
<p className="text-sm text-[color:var(--terminal-muted)]">Loading intelligence output...</p>
) : state.latestInsight ? (

View File

@@ -8,6 +8,7 @@
"@elysiajs/eden": "^1.4.8",
"@libsql/client": "^0.17.0",
"@tailwindcss/postcss": "^4.2.1",
"ai": "^6.0.104",
"better-auth": "^1.4.19",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -19,6 +20,7 @@
"react-dom": "^19.2.4",
"recharts": "^3.7.0",
"workflow": "^4.1.0-beta.60",
"zhipu-ai-provider": "^0.2.2",
},
"devDependencies": {
"@types/node": "^25.3.0",
@@ -34,6 +36,12 @@
},
},
"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=="],
"@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=="],
"@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=="],
"@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/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=="],
@@ -614,6 +624,8 @@
"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-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=="],
"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=="],
"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-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"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=="],
@@ -1468,6 +1484,8 @@
"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=="],
"@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-vercel/@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
"@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=="],
@@ -1772,6 +1792,10 @@
"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/@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>
<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)]">
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>
<Link
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>
{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)]">
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>
<Button className="mt-3 w-full" variant="ghost" onClick={() => void signOut()} disabled={isSigningOut}>
<LogOut className="size-4" />

View File

@@ -5,6 +5,3 @@ services:
environment:
BETTER_AUTH_BASE_URL: ${BETTER_AUTH_BASE_URL:-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_ADMIN_USER_IDS: ${BETTER_AUTH_ADMIN_USER_IDS:-}
BETTER_AUTH_TRUSTED_ORIGINS: ${BETTER_AUTH_TRUSTED_ORIGINS:-https://fiscal.b11studio.xyz}
OPENCLAW_BASE_URL: ${OPENCLAW_BASE_URL:-http://openclaw:4000}
OPENCLAW_API_KEY: ${OPENCLAW_API_KEY:-}
OPENCLAW_MODEL: ${OPENCLAW_MODEL:-zeroclaw}
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:-}
ZHIPU_API_KEY: ${ZHIPU_API_KEY:-}
ZHIPU_MODEL: ${ZHIPU_MODEL:-glm-4.7-flashx}
AI_TEMPERATURE: ${AI_TEMPERATURE:-0.2}
SEC_USER_AGENT: ${SEC_USER_AGENT:-Fiscal Clone <support@fiscal.local>}
WORKFLOW_TARGET_WORLD: local
WORKFLOW_LOCAL_DATA_DIR: ${WORKFLOW_LOCAL_DATA_DIR:-/app/.workflow-data}
@@ -32,9 +28,6 @@ services:
volumes:
- fiscal_sqlite_data:/app/data
- fiscal_workflow_data:/app/.workflow-data
depends_on:
openclaw:
condition: service_started
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:3000/api/health || exit 1"]
interval: 30s
@@ -43,15 +36,6 @@ services:
expose:
- "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:
fiscal_sqlite_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 { runOpenClawAnalysis } from '@/lib/server/openclaw';
import { runAiAnalysis } from '@/lib/server/ai';
import { buildPortfolioSummary } from '@/lib/server/portfolio';
import { getQuote } from '@/lib/server/prices';
import {
@@ -143,7 +143,7 @@ async function processAnalyzeFiling(task: Task) {
'Return concise sections: Thesis, Red Flags, Follow-up Questions, Portfolio Impact.'
].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, {
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.'
].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({
userId,

View File

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