Migrate AI runtime to SDK and hardcode Zhipu coding endpoint
This commit is contained in:
192
lib/server/ai.test.ts
Normal file
192
lib/server/ai.test.ts
Normal 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
190
lib/server/ai.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user