ai with hyper machine
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-06-26 15:28:47 +01:00
parent 2c4c9b0595
commit 7251bf305a
11 changed files with 501 additions and 130 deletions

View File

@@ -58,6 +58,24 @@ export interface AIProviderConfig {
enabled: boolean;
}
export type AIMachineMode = "local" | "hyper";
export interface AIProviderSettings {
machineMode: AIMachineMode;
profiles: {
local: AIProviderConfig;
hyper: AIProviderConfig;
};
}
export interface AIHealthCacheMeta {
fallbackActive?: boolean;
requestedMode?: AIMachineMode;
effectiveMode?: AIMachineMode;
}
export type AIHealthCache = AIHealthResult & AIHealthCacheMeta;
export interface AIBehaviorConfig {
personality: "supportive_mentor" | "wise_teacher" | "quiet_observer" | "academic_tutor" | "friendly_coach";
verbosity: "minimal" | "balanced" | "detailed";
@@ -88,6 +106,64 @@ export const DEFAULT_AI_PROVIDER: AIProviderConfig = {
enabled: true,
};
export const DEFAULT_HYPER_AI_PROVIDER: AIProviderConfig = {
type: "ollama",
baseUrl: env.hyperOllamaUrl,
model: env.hyperOllamaModelFast,
temperature: 0.7,
maxTokens: 2048,
timeoutMs: 120000,
enabled: true,
};
export const DEFAULT_AI_PROVIDER_SETTINGS: AIProviderSettings = {
machineMode: "local",
profiles: {
local: DEFAULT_AI_PROVIDER,
hyper: DEFAULT_HYPER_AI_PROVIDER,
},
};
function isLegacyProviderConfig(value: unknown): value is AIProviderConfig {
return (
typeof value === "object" &&
value !== null &&
"type" in value &&
"baseUrl" in value &&
!("machineMode" in value)
);
}
export function migrateProviderSettings(value: unknown): AIProviderSettings {
if (
typeof value === "object" &&
value !== null &&
"machineMode" in value &&
"profiles" in value
) {
const settings = value as AIProviderSettings;
return {
machineMode: settings.machineMode ?? "local",
profiles: {
local: { ...DEFAULT_AI_PROVIDER, ...settings.profiles?.local },
hyper: { ...DEFAULT_HYPER_AI_PROVIDER, ...settings.profiles?.hyper },
},
};
}
const local = isLegacyProviderConfig(value)
? { ...DEFAULT_AI_PROVIDER, ...value }
: DEFAULT_AI_PROVIDER;
return {
machineMode: "local",
profiles: {
local,
hyper: DEFAULT_HYPER_AI_PROVIDER,
},
};
}
export function redactUrl(url: string): string {
try {
const u = new URL(url);

View File

@@ -35,6 +35,22 @@ export const env = {
return process.env.OLLAMA_MODEL_PROSE ?? process.env.OLLAMA_MODEL_FAST ?? "llama3.2:3b";
},
get hyperOllamaUrl(): string {
return process.env.HYPER_OLLAMA_URL ?? "http://192.168.1.50:11434";
},
get hyperOllamaModelFast(): string {
return process.env.HYPER_OLLAMA_MODEL_FAST ?? "llama3.1:8b";
},
get hyperOllamaModelProse(): string {
return (
process.env.HYPER_OLLAMA_MODEL_PROSE ??
process.env.HYPER_OLLAMA_MODEL_FAST ??
"llama3.1:8b"
);
},
get openAiApiKey(): string | undefined {
return process.env.OPENAI_API_KEY;
},

View File

@@ -2,14 +2,21 @@ import { eq, and, desc } from "drizzle-orm";
import { db, settings, aiHealthLog } from "../db";
import {
DEFAULT_AI_BEHAVIOR,
DEFAULT_AI_PROVIDER,
DEFAULT_AI_PROVIDER_SETTINGS,
migrateProviderSettings,
type AIBehaviorConfig,
type AIProviderConfig,
type AIProviderSettings,
type AIHealthResult,
type AIHealthCache,
type AIMachineMode,
type GenerateTextRequest,
type GenerateTextResponse,
} from "../ai/types";
import { getProvider } from "../ai/provider-registry";
import { isAiTimeoutError } from "../ai/ai-normalize";
import { PERSONALITY_MODIFIERS, VERBOSITY_MODIFIERS } from "../ai/prompts/defaults";
import { SETTINGS_KEYS } from "@/lib/config";
import { SETTINGS_KEYS, env } from "@/lib/config";
export async function getAiBehaviorConfig(userId: string): Promise<AIBehaviorConfig> {
const [row] = await db
@@ -20,13 +27,26 @@ export async function getAiBehaviorConfig(userId: string): Promise<AIBehaviorCon
return { ...DEFAULT_AI_BEHAVIOR, ...(row.value as Partial<AIBehaviorConfig>) };
}
export async function getAiProviderConfig(userId: string): Promise<AIProviderConfig> {
export async function getAiProviderSettings(userId: string): Promise<AIProviderSettings> {
const [row] = await db
.select()
.from(settings)
.where(and(eq(settings.userId, userId), eq(settings.key, SETTINGS_KEYS.aiProvider)));
if (!row?.value) return DEFAULT_AI_PROVIDER;
return { ...DEFAULT_AI_PROVIDER, ...(row.value as Partial<AIProviderConfig>) };
if (!row?.value) return DEFAULT_AI_PROVIDER_SETTINGS;
return migrateProviderSettings(row.value);
}
export async function getAiProviderConfig(userId: string): Promise<AIProviderConfig> {
const providerSettings = await getAiProviderSettings(userId);
return providerSettings.profiles[providerSettings.machineMode];
}
export async function getProseModel(userId: string): Promise<string> {
const providerSettings = await getAiProviderSettings(userId);
if (providerSettings.machineMode === "hyper") {
return env.hyperOllamaModelProse;
}
return env.ollamaModelProse;
}
export async function saveAiBehaviorConfig(userId: string, config: Partial<AIBehaviorConfig>) {
@@ -43,9 +63,18 @@ export async function saveAiBehaviorConfig(userId: string, config: Partial<AIBeh
return merged;
}
export async function saveAiProviderConfig(userId: string, config: Partial<AIProviderConfig>) {
const current = await getAiProviderConfig(userId);
const merged = { ...current, ...config };
export async function saveAiProviderSettings(
userId: string,
config: Partial<AIProviderSettings>
): Promise<AIProviderSettings> {
const current = await getAiProviderSettings(userId);
const merged: AIProviderSettings = {
machineMode: config.machineMode ?? current.machineMode,
profiles: {
local: { ...current.profiles.local, ...config.profiles?.local },
hyper: { ...current.profiles.hyper, ...config.profiles?.hyper },
},
};
const { sql } = await import("drizzle-orm");
await db
.insert(settings)
@@ -57,32 +86,40 @@ export async function saveAiProviderConfig(userId: string, config: Partial<AIPro
return merged;
}
export async function getCachedHealth(userId: string): Promise<AIHealthResult | null> {
export async function saveAiProviderConfig(userId: string, config: Partial<AIProviderConfig>) {
const current = await getAiProviderSettings(userId);
const mode = current.machineMode;
return saveAiProviderSettings(userId, {
profiles: {
[mode]: { ...current.profiles[mode], ...config },
},
});
}
export async function getCachedHealth(userId: string): Promise<AIHealthCache | null> {
const [row] = await db
.select()
.from(settings)
.where(and(eq(settings.userId, userId), eq(settings.key, SETTINGS_KEYS.aiHealthCache)));
return (row?.value as AIHealthResult) ?? null;
return (row?.value as AIHealthCache) ?? null;
}
export async function runHealthCheck(userId: string): Promise<AIHealthResult> {
const config = await getAiProviderConfig(userId);
const provider = getProvider(config.type);
const result = await provider.healthCheck(config);
export function isConnectionError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
if (isAiTimeoutError(error)) return true;
const msg = error.message.toLowerCase();
return (
msg.includes("fetch failed") ||
msg.includes("connection failed") ||
msg.includes("econnrefused") ||
msg.includes("enotfound") ||
msg.includes("network") ||
msg.includes("unable to connect")
);
}
async function cacheHealthResult(userId: string, result: AIHealthCache) {
const { sql } = await import("drizzle-orm");
await db.insert(aiHealthLog).values({
userId,
providerType: config.type,
status: result.status,
latencyMs: result.latencyMs,
model: config.model,
contextLength: result.contextLength,
memoryUsageMb: result.memoryUsageMb,
baseUrlSafe: result.baseUrlSafe,
errorMessage: result.lastError,
});
await db
.insert(settings)
.values({ userId, key: SETTINGS_KEYS.aiHealthCache, value: result })
@@ -90,8 +127,111 @@ export async function runHealthCheck(userId: string): Promise<AIHealthResult> {
target: [settings.userId, settings.key],
set: { value: sql`excluded.value` },
});
}
return result;
export async function generateTextWithFallback(
userId: string,
req: GenerateTextRequest,
behavior: AIBehaviorConfig
): Promise<GenerateTextResponse & { fallbackActive: boolean; effectiveMode: AIMachineMode }> {
const providerSettings = await getAiProviderSettings(userId);
const requestedMode = providerSettings.machineMode;
const config = providerSettings.profiles[requestedMode];
const provider = getProvider(config.type);
try {
const res = await provider.generateText(config, req);
const cached = await getCachedHealth(userId);
if (cached?.fallbackActive) {
await cacheHealthResult(userId, {
...cached,
fallbackActive: false,
requestedMode,
effectiveMode: requestedMode,
});
}
return { ...res, fallbackActive: false, effectiveMode: requestedMode };
} catch (error) {
if (
requestedMode === "hyper" &&
!behavior.strictMode &&
isConnectionError(error)
) {
const localConfig = providerSettings.profiles.local;
const localProvider = getProvider(localConfig.type);
const res = await localProvider.generateText(localConfig, req);
const cached = await getCachedHealth(userId);
await cacheHealthResult(userId, {
...(cached ?? {
status: "online",
provider: localConfig.type,
model: localConfig.model,
latencyMs: res.latencyMs,
lastSuccessAt: new Date().toISOString(),
lastFailureAt: null,
lastError: null,
baseUrlSafe: null,
contextLength: null,
memoryUsageMb: null,
modelsAvailable: [],
}),
fallbackActive: true,
requestedMode: "hyper",
effectiveMode: "local",
lastError:
error instanceof Error
? `Hyper machine unreachable: ${error.message}`
: "Hyper machine unreachable",
});
return { ...res, fallbackActive: true, effectiveMode: "local" };
}
throw error;
}
}
export async function runHealthCheck(userId: string): Promise<AIHealthCache> {
const providerSettings = await getAiProviderSettings(userId);
const requestedMode = providerSettings.machineMode;
const config = providerSettings.profiles[requestedMode];
const provider = getProvider(config.type);
const result = await provider.healthCheck(config);
let cacheResult: AIHealthCache = {
...result,
fallbackActive: false,
requestedMode,
effectiveMode: requestedMode,
};
if (requestedMode === "hyper" && result.status === "offline") {
const localConfig = providerSettings.profiles.local;
const localProvider = getProvider(localConfig.type);
const localResult = await localProvider.healthCheck(localConfig);
if (localResult.status !== "offline") {
cacheResult = {
...localResult,
fallbackActive: true,
requestedMode: "hyper",
effectiveMode: "local",
lastError: result.lastError ?? "Hyper machine unreachable",
};
}
}
await db.insert(aiHealthLog).values({
userId,
providerType: cacheResult.provider,
status: cacheResult.status,
latencyMs: cacheResult.latencyMs,
model: cacheResult.model,
contextLength: cacheResult.contextLength,
memoryUsageMb: cacheResult.memoryUsageMb,
baseUrlSafe: cacheResult.baseUrlSafe,
errorMessage: cacheResult.lastError,
});
await cacheHealthResult(userId, cacheResult);
return cacheResult;
}
export async function getLastHealthLogs(userId: string, limit = 10) {

View File

@@ -4,6 +4,9 @@ import { teacherSchema } from "./ai";
vi.mock("./ai-config", () => ({
getAiBehaviorConfig: vi.fn(),
getAiProviderConfig: vi.fn(),
getAiProviderSettings: vi.fn(),
getProseModel: vi.fn(async () => "test-prose"),
generateTextWithFallback: vi.fn(),
buildSystemPrompt: vi.fn(() => "system"),
}));
@@ -19,7 +22,7 @@ vi.mock("./user", () => ({
requireUser: vi.fn(async () => ({ user: { id: "user-1", displayName: "Traveler" } })),
}));
import { getAiBehaviorConfig, getAiProviderConfig } from "./ai-config";
import { getAiBehaviorConfig, getAiProviderConfig, getAiProviderSettings, generateTextWithFallback } from "./ai-config";
import { getProvider } from "../ai/provider-registry";
const onlineAvailability = {
@@ -40,6 +43,14 @@ const onlineProviderConfig = {
timeoutMs: 30000,
};
const providerSettings = {
machineMode: "local" as const,
profiles: {
local: onlineProviderConfig,
hyper: onlineProviderConfig,
},
};
describe("teacherSchema", () => {
it("coerces string quiz answers to numbers", () => {
const parsed = teacherSchema.parse({
@@ -97,27 +108,31 @@ describe("generateTeacherContent", () => {
it("returns AI content when provider succeeds", async () => {
vi.mocked(getAiBehaviorConfig).mockResolvedValue(onlineAvailability);
vi.mocked(getAiProviderConfig).mockResolvedValue(onlineProviderConfig);
vi.mocked(getAiProviderSettings).mockResolvedValue(providerSettings);
vi.mocked(generateTextWithFallback).mockResolvedValue({
text: JSON.stringify({
overview: "Intro",
explanation: "Details here",
flashcards: [{ front: "What is Rust?", back: "A systems language" }],
quiz: [
{
question: "Rust is?",
options: ["Fast", "Slow"],
answer: 0,
},
],
researchAssignment: { steps: ["Read chapter 1"], expectedOutcome: "Notes" },
}),
model: "test",
latencyMs: 10,
fallbackActive: false,
effectiveMode: "local",
});
vi.mocked(getProvider).mockReturnValue({
type: "ollama",
healthCheck: vi.fn(async () => ({ status: "online" as const })),
listModels: vi.fn(async () => []),
generateText: vi.fn(async () => ({
text: JSON.stringify({
overview: "Intro",
explanation: "Details here",
flashcards: [{ front: "What is Rust?", back: "A systems language" }],
quiz: [
{
question: "Rust is?",
options: ["Fast", "Slow"],
answer: 0,
},
],
researchAssignment: { steps: ["Read chapter 1"], expectedOutcome: "Notes" },
}),
model: "test",
latencyMs: 10,
})),
generateText: vi.fn(),
});
const { generateTeacherContent } = await import("./ai");
@@ -145,15 +160,19 @@ describe("generateTeacherContent", () => {
it("returns fallback when AI JSON is invalid and strictMode is off", async () => {
vi.mocked(getAiBehaviorConfig).mockResolvedValue(onlineAvailability);
vi.mocked(getAiProviderConfig).mockResolvedValue(onlineProviderConfig);
vi.mocked(getAiProviderSettings).mockResolvedValue(providerSettings);
vi.mocked(generateTextWithFallback).mockResolvedValue({
text: "not valid json",
model: "test",
latencyMs: 10,
fallbackActive: false,
effectiveMode: "local",
});
vi.mocked(getProvider).mockReturnValue({
type: "ollama",
healthCheck: vi.fn(async () => ({ status: "online" as const })),
listModels: vi.fn(async () => []),
generateText: vi.fn(async () => ({
text: "not valid json",
model: "test",
latencyMs: 10,
})),
generateText: vi.fn(),
});
const { generateTeacherContent } = await import("./ai");

View File

@@ -17,12 +17,14 @@ import { parseAiJson } from "../ai/parse-json";
import {
getAiBehaviorConfig,
getAiProviderConfig,
getAiProviderSettings,
getProseModel,
generateTextWithFallback,
buildSystemPrompt,
} from "./ai-config";
import { getTemplateBody } from "./ai-templates";
import { renderTemplate } from "../ai/prompts/render";
import { requireUser } from "./user";
import { env } from "@/lib/config";
const questSchema = z.object({
quests: z
@@ -143,13 +145,23 @@ export async function getAiAvailability(userId: string) {
const behavior = await getAiBehaviorConfig(userId);
if (!behavior.enabled) return { canUse: false, online: false };
const providerConfig = await getAiProviderConfig(userId);
const providerSettings = await getAiProviderSettings(userId);
const providerConfig = providerSettings.profiles[providerSettings.machineMode];
if (!providerConfig.enabled) return { canUse: false, online: false };
try {
const provider = getProvider(providerConfig.type);
const health = await provider.healthCheck(providerConfig);
return { canUse: true, online: health.status === "online" };
if (health.status === "online") {
return { canUse: true, online: true };
}
if (providerSettings.machineMode === "hyper") {
const localConfig = providerSettings.profiles.local;
const localProvider = getProvider(localConfig.type);
const localHealth = await localProvider.healthCheck(localConfig);
return { canUse: true, online: localHealth.status === "online" };
}
return { canUse: true, online: false };
} catch {
return { canUse: true, online: false };
}
@@ -183,22 +195,25 @@ async function aiGenerate(
});
try {
const provider = getProvider(providerConfig.type);
const model =
modelOverride ??
(templateKey === "weekly_review"
? env.ollamaModelProse
? await getProseModel(userId)
: providerConfig.model);
const res = await provider.generateText(providerConfig, {
model,
prompt,
system,
temperature: behavior.creativity,
maxTokens: providerConfig.maxTokens,
format,
timeoutMs: providerConfig.timeoutMs,
});
const res = await generateTextWithFallback(
userId,
{
model,
prompt,
system,
temperature: behavior.creativity,
maxTokens: providerConfig.maxTokens,
format,
timeoutMs: providerConfig.timeoutMs,
},
behavior
);
return res.text;
} catch (e) {
if (behavior.strictMode) throw e;
@@ -381,7 +396,7 @@ export async function generateMentorReview(context: Record<string, unknown>, use
"system_core",
"system_weekly_review",
{ context: JSON.stringify(context) },
env.ollamaModelProse
await getProseModel(uid)
);
if (raw) {
try {
@@ -514,16 +529,19 @@ export async function generateChatReply(
.filter(Boolean)
.join("\n");
const provider = getProvider(providerConfig.type);
const res = await provider.generateText(providerConfig, {
model: providerConfig.model,
prompt,
system,
format: "text",
temperature: behavior.creativity,
maxTokens: Math.min(providerConfig.maxTokens, 800),
timeoutMs: providerConfig.timeoutMs,
});
const res = await generateTextWithFallback(
userId,
{
model: providerConfig.model,
prompt,
system,
format: "text",
temperature: behavior.creativity,
maxTokens: Math.min(providerConfig.maxTokens, 800),
timeoutMs: providerConfig.timeoutMs,
},
behavior
);
return res.text.trim() || null;
} catch (e) {
if (behavior.strictMode) throw e;

View File

@@ -2,8 +2,7 @@ import { z } from "zod";
import type { MemoryCategory, MemorySourceType } from "@adventureos/shared";
import { SENSITIVE_MEMORY_CATEGORIES } from "@adventureos/shared";
import { parseAiJson } from "@/lib/ai/parse-json";
import { getProvider } from "@/lib/ai/provider-registry";
import { getAiBehaviorConfig, getAiProviderConfig, buildSystemPrompt } from "./ai-config";
import { getAiBehaviorConfig, getAiProviderConfig, generateTextWithFallback, buildSystemPrompt } from "./ai-config";
import { createSuggestion, getMemoryLearningSettings } from "./ai-memory";
const candidateSchema = z.object({
@@ -43,16 +42,19 @@ If nothing clear, return { "candidates": [] }.`;
try {
const coreSystem = await getTemplateBody(userId);
const provider = getProvider(providerConfig.type);
const res = await provider.generateText(providerConfig, {
model: providerConfig.model,
prompt,
system: buildSystemPrompt(behavior, coreSystem),
format: "json",
temperature: 0.3,
maxTokens: 400,
timeoutMs: providerConfig.timeoutMs,
});
const res = await generateTextWithFallback(
userId,
{
model: providerConfig.model,
prompt,
system: buildSystemPrompt(behavior, coreSystem),
format: "json",
temperature: 0.3,
maxTokens: 400,
timeoutMs: providerConfig.timeoutMs,
},
behavior
);
const parsed = candidateSchema.safeParse(parseAiJson(res.text));
if (!parsed.success) return [];