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

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,