refactor: make AI runtime z.ai-only and default to glm-5

This commit is contained in:
2026-03-02 22:27:39 -05:00
parent 812c4803f2
commit da2ce23bab
9 changed files with 152 additions and 384 deletions

View File

@@ -16,15 +16,9 @@ BETTER_AUTH_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz
# Legacy OPENCLAW_* variables are removed and no longer read by the app. # 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 # Coding endpoint is hardcoded in runtime: https://api.z.ai/api/coding/paas/v4
ZHIPU_API_KEY= ZHIPU_API_KEY=
ZHIPU_MODEL=glm-4.7-flashx ZHIPU_MODEL=glm-5
AI_TEMPERATURE=0.2 AI_TEMPERATURE=0.2
# Local extraction model (Ollama, OpenAI-compatible API)
# For host Ollama from Docker, use http://host.docker.internal:11434
OLLAMA_BASE_URL=http://127.0.0.1:11434
OLLAMA_MODEL=qwen3:8b
OLLAMA_API_KEY=ollama
# SEC API etiquette # SEC API etiquette
SEC_USER_AGENT=Fiscal Clone <support@fiscal.local> SEC_USER_AGENT=Fiscal Clone <support@fiscal.local>

View File

@@ -14,9 +14,7 @@ Turbopack-first rebuild of a fiscal.ai-style terminal with Vercel AI SDK integra
- Eden Treaty for type-safe frontend API calls - Eden Treaty for type-safe frontend API calls
- Workflow DevKit Postgres World for background task execution durability - Workflow DevKit Postgres World for background task execution durability
- SQLite-backed app domain storage (watchlist, holdings, filings, task projection, insights) - SQLite-backed app domain storage (watchlist, holdings, filings, task projection, insights)
- Vercel AI SDK (`ai`) with dual-model routing: - Vercel AI SDK (`ai`) with Zhipu (`zhipu-ai-provider`) via Coding API (`https://api.z.ai/api/coding/paas/v4`)
- Ollama (`@ai-sdk/openai`) for lightweight filing extraction/parsing
- Zhipu (`zhipu-ai-provider`) for heavyweight narrative reports (`https://api.z.ai/api/coding/paas/v4`)
## Run locally ## Run locally
@@ -47,8 +45,7 @@ docker compose up --build -d
``` ```
For local Docker, host port mapping comes from `docker-compose.override.yml` (default `http://localhost:3000` via `APP_PORT`). 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 for heavy reports and calls Ollama for lightweight filing extraction. The app calls Zhipu directly via AI SDK for extraction and report generation.
When running in Docker and Ollama runs on the host, set `OLLAMA_BASE_URL=http://host.docker.internal:11434`.
Zhipu always targets the Coding API endpoint (`https://api.z.ai/api/coding/paas/v4`). Zhipu always targets the Coding API endpoint (`https://api.z.ai/api/coding/paas/v4`).
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 world data in Postgres volume `workflow_postgres_data`. The app stores SQLite data in Docker volume `fiscal_sqlite_data` (mounted to `/app/data`) and workflow world data in Postgres volume `workflow_postgres_data`.
@@ -100,13 +97,10 @@ BETTER_AUTH_BASE_URL=https://fiscal.b11studio.xyz
BETTER_AUTH_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz BETTER_AUTH_TRUSTED_ORIGINS=https://fiscal.b11studio.xyz
ZHIPU_API_KEY= ZHIPU_API_KEY=
ZHIPU_MODEL=glm-4.7-flashx ZHIPU_MODEL=glm-5
# optional generation tuning # optional generation tuning
AI_TEMPERATURE=0.2 AI_TEMPERATURE=0.2
OLLAMA_BASE_URL=http://127.0.0.1:11434
OLLAMA_MODEL=qwen3:8b
OLLAMA_API_KEY=ollama
SEC_USER_AGENT=Fiscal Clone <support@fiscal.local> SEC_USER_AGENT=Fiscal Clone <support@fiscal.local>
WORKFLOW_TARGET_WORLD=@workflow/world-postgres WORKFLOW_TARGET_WORLD=@workflow/world-postgres
@@ -119,8 +113,7 @@ WORKFLOW_LOCAL_DATA_DIR=.workflow-data
WORKFLOW_LOCAL_QUEUE_CONCURRENCY=100 WORKFLOW_LOCAL_QUEUE_CONCURRENCY=100
``` ```
If `ZHIPU_API_KEY` is unset, the app uses local fallback analysis so task workflows still run. `ZHIPU_API_KEY` is required for AI workloads (extraction and report generation). Missing or invalid credentials fail AI tasks.
If Ollama is unavailable, filing extraction falls back to deterministic metadata-based extraction and still proceeds to heavy report generation.
`ZHIPU_BASE_URL` is deprecated and ignored; runtime always uses `https://api.z.ai/api/coding/paas/v4`. `ZHIPU_BASE_URL` is deprecated and ignored; runtime always uses `https://api.z.ai/api/coding/paas/v4`.
## API surface ## API surface

View File

@@ -5,7 +5,6 @@
"": { "": {
"name": "fiscal-frontend", "name": "fiscal-frontend",
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^2.0.62",
"@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",
@@ -42,11 +41,9 @@
"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/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/openai": ["@ai-sdk/openai@2.0.95", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2CABPaa1UNh7dPyZUIB/Dc4AbvJioFnmryRx45sx7ezBSOdR0zxG6gbrSd/fZ0GVbptSZeLmF9omu10d/GxmJA=="], "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], "@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=="],
"@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=="],
"@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=="],
@@ -1528,10 +1525,6 @@
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/gateway/@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=="],
"@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], "@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@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=="],
@@ -1786,10 +1779,6 @@
"@xhmikosr/downloader/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/downloader/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=="],
"ai/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"ai/@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=="],
"ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
@@ -1872,6 +1861,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

@@ -37,11 +37,8 @@ services:
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}
ZHIPU_API_KEY: ${ZHIPU_API_KEY:-} ZHIPU_API_KEY: ${ZHIPU_API_KEY:-}
ZHIPU_MODEL: ${ZHIPU_MODEL:-glm-4.7-flashx} ZHIPU_MODEL: ${ZHIPU_MODEL:-glm-5}
AI_TEMPERATURE: ${AI_TEMPERATURE:-0.2} AI_TEMPERATURE: ${AI_TEMPERATURE:-0.2}
OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://127.0.0.1:11434}
OLLAMA_MODEL: ${OLLAMA_MODEL:-qwen3:8b}
OLLAMA_API_KEY: ${OLLAMA_API_KEY:-ollama}
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: ${WORKFLOW_TARGET_WORLD:-@workflow/world-postgres} WORKFLOW_TARGET_WORLD: ${WORKFLOW_TARGET_WORLD:-@workflow/world-postgres}
WORKFLOW_POSTGRES_URL: ${WORKFLOW_POSTGRES_URL:-postgres://workflow:workflow@workflow-postgres:5432/workflow} WORKFLOW_POSTGRES_URL: ${WORKFLOW_POSTGRES_URL:-postgres://workflow:workflow@workflow-postgres:5432/workflow}

View File

@@ -21,9 +21,10 @@ describe('ai config and runtime', () => {
warn: () => {} warn: () => {}
}); });
expect(config.provider).toBe('zhipu');
expect(config.apiKey).toBe('key'); expect(config.apiKey).toBe('key');
expect(config.baseUrl).toBe(CODING_API_BASE_URL); expect(config.baseUrl).toBe(CODING_API_BASE_URL);
expect(config.model).toBe('glm-4.7-flashx'); expect(config.model).toBe('glm-5');
expect(config.temperature).toBe(0.2); expect(config.temperature).toBe(0.2);
}); });
@@ -39,7 +40,7 @@ describe('ai config and runtime', () => {
expect(config.baseUrl).toBe(CODING_API_BASE_URL); expect(config.baseUrl).toBe(CODING_API_BASE_URL);
}); });
it('clamps temperature into [0, 2]', () => { it('clamps report temperature into [0, 2]', () => {
const negative = getAiConfig({ const negative = getAiConfig({
env: { env: {
ZHIPU_API_KEY: 'key', ZHIPU_API_KEY: 'key',
@@ -68,23 +69,50 @@ describe('ai config and runtime', () => {
expect(invalid.temperature).toBe(0.2); expect(invalid.temperature).toBe(0.2);
}); });
it('returns fallback output when ZHIPU_API_KEY is missing', async () => { it('uses extraction workload with zhipu config and zero temperature', async () => {
const generate = mock(async () => ({ text: 'should-not-be-used' })); const createModel = mock((config: {
provider: string;
apiKey?: string;
model: string;
baseUrl: string;
temperature: number;
}) => {
expect(config.provider).toBe('zhipu');
expect(config.apiKey).toBe('new-key');
expect(config.baseUrl).toBe(CODING_API_BASE_URL);
expect(config.model).toBe('glm-5');
expect(config.temperature).toBe(0);
return { modelId: config.model };
});
const generate = mock(async (input: {
model: unknown;
system?: string;
prompt: string;
temperature: number;
maxRetries?: number;
}) => {
expect(input.system).toBe('Return strict JSON only.');
expect(input.prompt).toBe('Extract this filing');
expect(input.temperature).toBe(0);
expect(input.maxRetries).toBe(0);
return { text: '{"summary":"ok"}' };
});
const result = await runAiAnalysis( const result = await runAiAnalysis('Extract this filing', 'Return strict JSON only.', {
'Prompt line one\nPrompt line two', env: {
'System prompt', ZHIPU_API_KEY: 'new-key'
{ },
env: {},
warn: () => {}, warn: () => {},
workload: 'extraction',
createModel,
generate generate
} });
);
expect(result.provider).toBe('local-fallback'); expect(result.provider).toBe('zhipu');
expect(result.model).toBe('glm-4.7-flashx'); expect(result.model).toBe('glm-5');
expect(result.text).toContain('AI SDK fallback mode is active'); expect(result.text).toBe('{"summary":"ok"}');
expect(generate).not.toHaveBeenCalled(); expect(createModel).toHaveBeenCalledTimes(1);
expect(generate).toHaveBeenCalledTimes(1);
}); });
it('warns once when ZHIPU_BASE_URL is set because coding endpoint is hardcoded', () => { it('warns once when ZHIPU_BASE_URL is set because coding endpoint is hardcoded', () => {
@@ -103,11 +131,13 @@ describe('ai config and runtime', () => {
it('uses configured ZHIPU values and injected generator when API key exists', async () => { it('uses configured ZHIPU values and injected generator when API key exists', async () => {
const createModel = mock((config: { const createModel = mock((config: {
provider: string;
apiKey?: string; apiKey?: string;
model: string; model: string;
baseUrl: string; baseUrl: string;
temperature: number; temperature: number;
}) => { }) => {
expect(config.provider).toBe('zhipu');
expect(config.apiKey).toBe('new-key'); expect(config.apiKey).toBe('new-key');
expect(config.baseUrl).toBe(CODING_API_BASE_URL); expect(config.baseUrl).toBe(CODING_API_BASE_URL);
expect(config.model).toBe('glm-4-plus'); expect(config.model).toBe('glm-4-plus');
@@ -147,6 +177,29 @@ describe('ai config and runtime', () => {
expect(result.text).toBe('Generated insight'); expect(result.text).toBe('Generated insight');
}); });
it('throws when report workload runs without ZHIPU_API_KEY', async () => {
await expect(
runAiAnalysis('Analyze this filing', undefined, {
env: {},
warn: () => {},
createModel: () => ({}),
generate: async () => ({ text: 'should-not-be-used' })
})
).rejects.toThrow('ZHIPU_API_KEY is required for AI workloads');
});
it('throws when extraction workload runs without ZHIPU_API_KEY', async () => {
await expect(
runAiAnalysis('Extract this filing', 'Return strict JSON only.', {
env: {},
warn: () => {},
workload: 'extraction',
createModel: () => ({}),
generate: async () => ({ text: 'should-not-be-used' })
})
).rejects.toThrow('ZHIPU_API_KEY is required for AI workloads');
});
it('throws when AI generation returns an empty response', async () => { it('throws when AI generation returns an empty response', async () => {
await expect( await expect(
runAiAnalysis('Analyze this filing', undefined, { runAiAnalysis('Analyze this filing', undefined, {
@@ -158,112 +211,7 @@ describe('ai config and runtime', () => {
).rejects.toThrow('AI SDK returned an empty response'); ).rejects.toThrow('AI SDK returned an empty response');
}); });
it('uses ollama defaults for extraction workload config', () => { it('keeps throwing unknown provider errors', async () => {
const config = getExtractionAiConfig({
env: {},
warn: () => {}
});
expect(config.provider).toBe('ollama');
expect(config.baseUrl).toBe('http://127.0.0.1:11434');
expect(config.model).toBe('qwen3:8b');
expect(config.apiKey).toBe('ollama');
expect(config.temperature).toBe(0);
});
it('uses extraction workload and returns ollama provider on success', async () => {
const createModel = mock((config: {
provider: string;
apiKey?: string;
model: string;
baseUrl: string;
temperature: number;
}) => {
expect(config.provider).toBe('ollama');
expect(config.baseUrl).toBe('http://127.0.0.1:11434');
expect(config.model).toBe('qwen3:8b');
expect(config.temperature).toBe(0);
return { modelId: config.model };
});
const generate = mock(async () => ({ text: '{"summary":"ok","keyPoints":[],"redFlags":[],"followUpQuestions":[],"portfolioSignals":[],"confidence":0.6}' }));
const result = await runAiAnalysis('Extract this filing', 'Return JSON', {
env: {
OLLAMA_MODEL: 'qwen3:8b'
},
warn: () => {},
workload: 'extraction',
createModel,
generate
});
expect(createModel).toHaveBeenCalledTimes(1);
expect(generate).toHaveBeenCalledTimes(1);
expect(result.provider).toBe('ollama');
expect(result.model).toBe('qwen3:8b');
});
it('falls back to local text when extraction workload generation fails', async () => {
const result = await runAiAnalysis('Extract this filing', 'Return JSON', {
env: {},
warn: () => {},
workload: 'extraction',
createModel: () => ({}),
generate: async () => {
throw new Error('ollama unavailable');
}
});
expect(result.provider).toBe('local-fallback');
expect(result.model).toBe('qwen3:8b');
expect(result.text).toContain('AI SDK fallback mode is active');
});
it('falls back to local text when report workload fails with insufficient balance', async () => {
const warn = mock((_message: string) => {});
const result = await runAiAnalysis('Analyze this filing', 'Use concise style', {
env: {
ZHIPU_API_KEY: 'new-key'
},
warn,
createModel: () => ({}),
generate: async () => {
throw new Error('AI_RetryError: Failed after 3 attempts. Last error: Insufficient balance or no resource package. Please recharge.');
}
});
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(warn).toHaveBeenCalledTimes(1);
});
it('falls back to local text when report workload cause contains insufficient balance', async () => {
const warn = mock((_message: string) => {});
const result = await runAiAnalysis('Analyze this filing', 'Use concise style', {
env: {
ZHIPU_API_KEY: 'new-key'
},
warn,
createModel: () => ({}),
generate: async () => {
const retryError = new Error('AI_RetryError: Failed after 3 attempts.');
(retryError as Error & { cause?: unknown }).cause = new Error(
'Last error: Insufficient balance or no resource package. Please recharge.'
);
throw retryError;
}
});
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(warn).toHaveBeenCalledTimes(1);
});
it('keeps throwing unknown report workload errors', async () => {
await expect( await expect(
runAiAnalysis('Analyze this filing', 'Use concise style', { runAiAnalysis('Analyze this filing', 'Use concise style', {
env: { env: {
@@ -277,4 +225,21 @@ describe('ai config and runtime', () => {
}) })
).rejects.toThrow('unexpected schema mismatch'); ).rejects.toThrow('unexpected schema mismatch');
}); });
it('returns extraction config with same zhipu model and zero temperature', () => {
const config = getExtractionAiConfig({
env: {
ZHIPU_API_KEY: 'new-key',
ZHIPU_MODEL: 'glm-4-plus',
AI_TEMPERATURE: '0.9'
},
warn: () => {}
});
expect(config.provider).toBe('zhipu');
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);
});
}); });

View File

@@ -1,9 +1,8 @@
import { createOpenAI } from '@ai-sdk/openai';
import { generateText } from 'ai'; import { generateText } from 'ai';
import { createZhipu } from 'zhipu-ai-provider'; import { createZhipu } from 'zhipu-ai-provider';
type AiWorkload = 'report' | 'extraction'; type AiWorkload = 'report' | 'extraction';
type AiProvider = 'zhipu' | 'ollama'; type AiProvider = 'zhipu';
type AiConfig = { type AiConfig = {
provider: AiProvider; provider: AiProvider;
@@ -39,9 +38,6 @@ type RunAiAnalysisOptions = GetAiConfigOptions & {
}; };
const CODING_API_BASE_URL = 'https://api.z.ai/api/coding/paas/v4'; const CODING_API_BASE_URL = 'https://api.z.ai/api/coding/paas/v4';
const OLLAMA_BASE_URL = 'http://127.0.0.1:11434';
const OLLAMA_MODEL = 'qwen3:8b';
const OLLAMA_API_KEY = 'ollama';
let warnedIgnoredZhipuBaseUrl = false; let warnedIgnoredZhipuBaseUrl = false;
@@ -80,128 +76,13 @@ function warnIgnoredZhipuBaseUrl(env: EnvSource, warn: (message: string) => void
); );
} }
function fallbackResponse(prompt: string) {
const clipped = prompt.split('\n').slice(0, 6).join(' ').slice(0, 260);
return [
'AI SDK fallback mode is active (live model configuration is missing or unavailable).',
'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 toOpenAiCompatibleBaseUrl(baseUrl: string) {
const normalized = baseUrl.endsWith('/')
? baseUrl.slice(0, -1)
: baseUrl;
return normalized.endsWith('/v1')
? normalized
: `${normalized}/v1`;
}
function asErrorMessage(error: unknown) {
if (error instanceof Error && error.message) {
return error.message;
}
return String(error);
}
function errorSearchText(error: unknown) {
const chunks: string[] = [];
const seen = new Set<unknown>();
const visit = (value: unknown) => {
if (value === null || value === undefined) {
return;
}
if (typeof value === 'string') {
const normalized = value.trim();
if (normalized.length > 0) {
chunks.push(normalized);
}
return;
}
if (typeof value !== 'object') {
chunks.push(String(value));
return;
}
if (seen.has(value)) {
return;
}
seen.add(value);
if (value instanceof Error) {
if (value.message) {
chunks.push(value.message);
}
const withCause = value as Error & { cause?: unknown };
if (withCause.cause !== undefined) {
visit(withCause.cause);
}
return;
}
const record = value as Record<string, unknown>;
visit(record.message);
visit(record.error);
visit(record.reason);
visit(record.detail);
visit(record.details);
visit(record.cause);
};
visit(error);
return chunks.join('\n');
}
const REPORT_FALLBACK_ERROR_PATTERNS: RegExp[] = [
/insufficient balance/i,
/no resource package/i,
/insufficient quota/i,
/quota exceeded/i,
/insufficient credit/i,
/invalid api key/i,
/authentication/i,
/unauthorized/i,
/forbidden/i,
/payment required/i,
/recharge/i,
/unable to connect/i,
/network/i,
/timeout/i,
/timed out/i,
/econnrefused/i
];
function shouldFallbackReportError(error: unknown) {
const searchText = errorSearchText(error) || asErrorMessage(error);
return REPORT_FALLBACK_ERROR_PATTERNS.some((pattern) => pattern.test(searchText));
}
function defaultCreateModel(config: AiConfig) { function defaultCreateModel(config: AiConfig) {
if (config.provider === 'zhipu') {
const zhipu = createZhipu({ const zhipu = createZhipu({
apiKey: config.apiKey, apiKey: config.apiKey,
baseURL: config.baseUrl baseURL: config.baseUrl
}); });
return zhipu(config.model); return zhipu(config.model);
}
const openai = createOpenAI({
apiKey: config.apiKey ?? OLLAMA_API_KEY,
baseURL: toOpenAiCompatibleBaseUrl(config.baseUrl)
});
return openai.chat(config.model);
} }
async function defaultGenerate(input: AiGenerateInput): Promise<AiGenerateOutput> { async function defaultGenerate(input: AiGenerateInput): Promise<AiGenerateOutput> {
@@ -228,21 +109,16 @@ export function getReportAiConfig(options?: GetAiConfigOptions) {
provider: 'zhipu', provider: 'zhipu',
apiKey: envValue('ZHIPU_API_KEY', env), apiKey: envValue('ZHIPU_API_KEY', env),
baseUrl: CODING_API_BASE_URL, baseUrl: CODING_API_BASE_URL,
model: envValue('ZHIPU_MODEL', env) ?? 'glm-4.7-flashx', model: envValue('ZHIPU_MODEL', env) ?? 'glm-5',
temperature: parseTemperature(envValue('AI_TEMPERATURE', env)) temperature: parseTemperature(envValue('AI_TEMPERATURE', env))
} satisfies AiConfig; } satisfies AiConfig;
} }
export function getExtractionAiConfig(options?: GetAiConfigOptions) { export function getExtractionAiConfig(options?: GetAiConfigOptions) {
const env = options?.env ?? process.env;
return { return {
provider: 'ollama', ...getReportAiConfig(options),
apiKey: envValue('OLLAMA_API_KEY', env) ?? OLLAMA_API_KEY,
baseUrl: envValue('OLLAMA_BASE_URL', env) ?? OLLAMA_BASE_URL,
model: envValue('OLLAMA_MODEL', env) ?? OLLAMA_MODEL,
temperature: 0 temperature: 0
} satisfies AiConfig; };
} }
export function isAiConfigured(options?: GetAiConfigOptions) { export function isAiConfigured(options?: GetAiConfigOptions) {
@@ -256,19 +132,12 @@ export async function runAiAnalysis(prompt: string, systemPrompt?: string, optio
? getExtractionAiConfig(options) ? getExtractionAiConfig(options)
: getReportAiConfig(options); : getReportAiConfig(options);
if (workload === 'report' && !config.apiKey) { if (!config.apiKey) {
return { throw new Error('ZHIPU_API_KEY is required for AI workloads');
provider: 'local-fallback',
model: config.model,
text: fallbackResponse(prompt)
};
} }
const createModel = options?.createModel ?? defaultCreateModel; const createModel = options?.createModel ?? defaultCreateModel;
const generate = options?.generate ?? defaultGenerate; const generate = options?.generate ?? defaultGenerate;
const warn = options?.warn ?? console.warn;
try {
const model = createModel(config); const model = createModel(config);
const result = await generate({ const result = await generate({
@@ -281,14 +150,6 @@ export async function runAiAnalysis(prompt: string, systemPrompt?: string, optio
const text = result.text.trim(); const text = result.text.trim();
if (!text) { if (!text) {
if (workload === 'extraction') {
return {
provider: 'local-fallback',
model: config.model,
text: fallbackResponse(prompt)
};
}
throw new Error('AI SDK returned an empty response'); throw new Error('AI SDK returned an empty response');
} }
@@ -297,29 +158,6 @@ export async function runAiAnalysis(prompt: string, systemPrompt?: string, optio
model: config.model, model: config.model,
text text
}; };
} catch (error) {
if (workload === 'report' && shouldFallbackReportError(error)) {
warn(`[AI SDK] Report fallback activated: ${asErrorMessage(error)}`);
return {
provider: 'local-fallback',
model: config.model,
text: fallbackResponse(prompt)
};
}
if (workload === 'extraction') {
warn(`[AI SDK] Extraction fallback activated: ${asErrorMessage(error)}`);
return {
provider: 'local-fallback',
model: config.model,
text: fallbackResponse(prompt)
};
}
throw error;
}
} }
export function __resetAiWarningsForTests() { export function __resetAiWarningsForTests() {

View File

@@ -32,8 +32,8 @@ function filingWithExtraction(): Filing {
confidence: 0.4 confidence: 0.4
}, },
extractionMeta: { extractionMeta: {
provider: 'ollama', provider: 'zhipu',
model: 'qwen3:8b', model: 'glm-4.7-flashx',
source: 'primary_document', source: 'primary_document',
generatedAt: '2026-02-01T00:00:00.000Z' generatedAt: '2026-02-01T00:00:00.000Z'
} }

View File

@@ -689,27 +689,23 @@ async function processAnalyzeFiling(task: Task) {
source: 'metadata_fallback', source: 'metadata_fallback',
generatedAt: new Date().toISOString() generatedAt: new Date().toISOString()
}; };
let filingDocument: Awaited<ReturnType<typeof fetchPrimaryFilingText>> | null = null;
try { try {
await setProjectionStage(task, 'analyze.fetch_document', 'Fetching primary filing document'); await setProjectionStage(task, 'analyze.fetch_document', 'Fetching primary filing document');
const filingDocument = await fetchPrimaryFilingText({ filingDocument = await fetchPrimaryFilingText({
filingUrl: filing.filing_url, filingUrl: filing.filing_url,
cik: filing.cik, cik: filing.cik,
accessionNumber: filing.accession_number, accessionNumber: filing.accession_number,
primaryDocument: filing.primary_document ?? null primaryDocument: filing.primary_document ?? null
}); });
} catch {
filingDocument = null;
}
if (filingDocument?.text) { if (filingDocument?.text) {
await setProjectionStage(task, 'analyze.extract', 'Generating extraction context from filing text'); await setProjectionStage(task, 'analyze.extract', 'Generating extraction context from filing text');
const ruleBasedExtraction = buildRuleBasedExtraction(filing, filingDocument.text); const ruleBasedExtraction = buildRuleBasedExtraction(filing, filingDocument.text);
extraction = ruleBasedExtraction;
extractionMeta = {
provider: 'deterministic-fallback',
model: 'filing-rule-based',
source: filingDocument.source,
generatedAt: new Date().toISOString()
};
const extractionResult = await runAiAnalysis( const extractionResult = await runAiAnalysis(
extractionPrompt(filing, filingDocument.text), extractionPrompt(filing, filingDocument.text),
'Return strict JSON only.', 'Return strict JSON only.',
@@ -717,25 +713,18 @@ async function processAnalyzeFiling(task: Task) {
); );
const parsed = parseExtractionPayload(extractionResult.text); const parsed = parseExtractionPayload(extractionResult.text);
if (parsed) { if (!parsed) {
throw new Error('Extraction output invalid JSON schema');
}
extraction = mergeExtractionWithFallback(parsed, ruleBasedExtraction); extraction = mergeExtractionWithFallback(parsed, ruleBasedExtraction);
extractionMeta = { extractionMeta = {
provider: extractionResult.provider === 'local-fallback' ? 'deterministic-fallback' : 'ollama', provider: 'zhipu',
model: extractionResult.model, model: extractionResult.model,
source: filingDocument.source, source: filingDocument.source,
generatedAt: new Date().toISOString() generatedAt: new Date().toISOString()
}; };
} }
}
} catch {
extraction = defaultExtraction;
extractionMeta = {
provider: 'deterministic-fallback',
model: 'metadata-fallback',
source: 'metadata_fallback',
generatedAt: new Date().toISOString()
};
}
await setProjectionStage(task, 'analyze.generate_report', 'Generating final filing analysis report'); await setProjectionStage(task, 'analyze.generate_report', 'Generating final filing analysis report');
const analysis = await runAiAnalysis( const analysis = await runAiAnalysis(

View File

@@ -16,7 +16,6 @@
"test:e2e:workflow": "RUN_TASK_WORKFLOW_E2E=1 bun test lib/server/api/task-workflow-hybrid.e2e.test.ts" "test:e2e:workflow": "RUN_TASK_WORKFLOW_E2E=1 bun test lib/server/api/task-workflow-hybrid.e2e.test.ts"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^2.0.62",
"@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",