Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Tests for the browser inference Worker script's extractText logic.
*
* The actual extractText function is embedded in WORKER_SCRIPT as a string,
* so we evaluate the function independently to verify its behavior with
* both string and chat-format (array of message objects) generated_text.
*/

import { assertEquals } from "#std/assert";
import { describe, it } from "#std/testing/bdd";

// Mirror of the extractText function from WORKER_SCRIPT.
// Keep in sync with worker-script.ts.
// deno-lint-ignore no-explicit-any
function extractText(generated: any): string {
if (typeof generated === "string") return generated;
if (Array.isArray(generated)) {
const last = generated[generated.length - 1];
return last?.content ?? "";
}
return "";
}

describe("worker extractText", () => {
it("returns plain string as-is", () => {
assertEquals(extractText("Hello world"), "Hello world");
});

it("extracts last message content from chat-format array", () => {
const chatOutput = [
{ role: "assistant", content: "Hello! How can I help?" },
];
assertEquals(extractText(chatOutput), "Hello! How can I help?");
});

it("extracts last message from multi-message array", () => {
const chatOutput = [
{ role: "user", content: "Hi" },
{ role: "assistant", content: "Hey there!" },
];
assertEquals(extractText(chatOutput), "Hey there!");
});

it("returns empty string for empty array", () => {
assertEquals(extractText([]), "");
});

it("returns empty string for null/undefined", () => {
assertEquals(extractText(null), "");
assertEquals(extractText(undefined), "");
});

it("returns empty string when last message has no content", () => {
assertEquals(extractText([{ role: "assistant" }]), "");
});
});
18 changes: 15 additions & 3 deletions src/agent/react/use-chat/browser-inference/worker-script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,22 +61,34 @@ self.onmessage = async (event) => {
}
chatMessages.push(...messages);

// Helper: generated_text is a plain string for raw prompts but an array
// of {role, content} message objects when using chat format. Extract the
// last assistant message's content in either case.
function extractText(generated) {
if (typeof generated === "string") return generated;
if (Array.isArray(generated)) {
const last = generated[generated.length - 1];
return last?.content ?? "";
}
return "";
}

const result = await pipe(chatMessages, {
max_new_tokens: options?.maxNewTokens ?? 512,
temperature: options?.temperature ?? 0.7,
do_sample: true,
return_full_text: false,
callback_function: (output) => {
if (!generating) return;
const text = output?.[0]?.generated_text;
if (typeof text === "string") {
const text = extractText(output?.[0]?.generated_text);
if (text) {
self.postMessage({ type: "token", id, token: text });
}
},
});

generating = false;
const finalText = result?.[0]?.generated_text ?? "";
const finalText = extractText(result?.[0]?.generated_text);
self.postMessage({ type: "done", id, text: finalText });
} catch (error) {
generating = false;
Expand Down
9 changes: 8 additions & 1 deletion src/agent/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
type MessagePart,
type ToolCall,
} from "../types.ts";
import { resolveModel } from "#veryfront/provider";
import { ensureModelReady, resolveModel } from "#veryfront/provider";
import { executeTool } from "#veryfront/tool";
import { generateId } from "#veryfront/utils/id.ts";
import { detectPlatform, getPlatformCapabilities } from "#veryfront/platform/core-platform.ts";
Expand Down Expand Up @@ -162,6 +162,13 @@ export class AgentRuntime {
// return a proper error response (503) instead of a 200 with an error event.
const languageModel = resolveModel(requestedModel);

// Eagerly verify the model runtime is available. For local models this
// checks that @huggingface/transformers can be imported. Must happen
// BEFORE creating the ReadableStream so no_ai_available errors propagate
// to the caller (createChatHandler) who returns a 503 with browser fallback
// info, instead of being swallowed as an in-band SSE error in a 200 response.
await ensureModelReady(languageModel);

return new ReadableStream<Uint8Array>({
start: async (controller) => {
try {
Expand Down
1 change: 1 addition & 0 deletions src/provider/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

export {
clearModelProviders,
ensureModelReady,
getRegisteredModelProviders,
hasModelProvider,
registerModelProvider,
Expand Down
6 changes: 4 additions & 2 deletions src/provider/local/ai-sdk-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,10 @@ function convertPrompt(prompt: any[]): ChatMessage[] {
export function createLocalModel(modelId?: string): LanguageModel {
const resolvedId = modelId || DEFAULT_LOCAL_MODEL;

// deno-lint-ignore no-explicit-any
const model: any = {
const model = {
/** Marker so ensureModelReady() can distinguish real local-engine models
* from mock/custom providers that happen to use provider:"local". */
_isVfLocalModel: true as const,
specificationVersion: "v2" as const,
provider: "local",
modelId: `local/${resolvedId}`,
Expand Down
8 changes: 7 additions & 1 deletion src/provider/local/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@

export { createLocalModel } from "./ai-sdk-adapter.ts";
export { isLocalAIDisabled } from "./env.ts";
export { generate, generateStream, isModelLoaded, preloadModel } from "./local-engine.ts";
export {
generate,
generateStream,
isModelLoaded,
preloadModel,
verifyLocalRuntime,
} from "./local-engine.ts";
export type { ChatMessage, GenerateOptions } from "./local-engine.ts";
export { DEFAULT_LOCAL_MODEL, getLocalModelIds, resolveLocalModel } from "./model-catalog.ts";
export type { ModelInfo } from "./model-catalog.ts";
44 changes: 43 additions & 1 deletion src/provider/local/local-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import { serverLogger } from "#veryfront/utils";
import { createError, toError } from "#veryfront/errors/veryfront-error.ts";
import { type ModelInfo, resolveLocalModel } from "./model-catalog.ts";
import { DEFAULT_LOCAL_MODEL, type ModelInfo, resolveLocalModel } from "./model-catalog.ts";
import { isLocalAIDisabled } from "./env.ts";

const logger = serverLogger.component("local-llm");
Expand Down Expand Up @@ -131,10 +131,52 @@ async function loadPipeline(modelInfo: ModelInfo): Promise<Pipeline> {
return await loadPromise;
} catch (error) {
loadingLocks.delete(cacheKey);

// Convert ONNX / native-addon errors to no_ai_available so they propagate
// correctly through the chat handler (503) instead of being swallowed as
// in-band SSE errors inside a 200 response stream.
const msg = error instanceof Error ? error.message : String(error);
if (
msg.includes("onnx") || msg.includes("ONNX") ||
msg.includes("dlopen") || msg.includes("dynamic linking") ||
msg.includes("native module") || msg.includes("SharedArrayBuffer")
) {
transformersModule = null;
throw toError(
createError({
type: "no_ai_available",
message:
"Local AI model unavailable — native ONNX Runtime is not supported in this environment " +
"(e.g. compiled binaries). Set OPENAI_API_KEY, ANTHROPIC_API_KEY, or GOOGLE_API_KEY " +
"in your .env file to use a cloud provider instead.",
}),
);
}
throw error;
}
}

/**
* Eagerly verify that the local AI runtime (@huggingface/transformers + ONNX)
* is available by loading the default model pipeline.
*
* Call this *before* creating the HTTP response stream so that failures surface
* as a thrown error (→ 503) rather than being swallowed inside a ReadableStream
* (→ 200 with in-band SSE error).
*
* In compiled binaries, `import("@huggingface/transformers")` itself fails
* because `onnxruntime-node` eagerly `require()`s a native `.node` addon at
* import time and the addon isn't embedded in the binary. In dev mode (Deno)
* the native addon exists on disk so the import succeeds, but `pipeline()` can
* still fail if the ONNX model files are missing. Either way this function
* surfaces the error before the response stream is created. The pipeline is
* cached after the first successful call, so subsequent checks are instant.
*/
export async function verifyLocalRuntime(modelId?: string): Promise<void> {
const modelInfo = resolveLocalModel(modelId || DEFAULT_LOCAL_MODEL);
await loadPipeline(modelInfo);
}

/**
* Generate text in a streaming fashion using an async generator.
*
Expand Down
49 changes: 47 additions & 2 deletions src/provider/local/local-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
* for fast CI — run manually with `--filter "local-engine"`.
*/

import { assertEquals, assertExists } from "#std/assert";
import { describe, it } from "#std/testing/bdd";
import { assertEquals, assertExists, assertRejects } from "#std/assert";
import { afterEach, describe, it } from "#std/testing/bdd";
import { DEFAULT_LOCAL_MODEL, getLocalModelIds, resolveLocalModel } from "./model-catalog.ts";
import { createLocalModel } from "./ai-sdk-adapter.ts";
import { clearModelProviders, ensureModelReady } from "../model-registry.ts";
import { fromError } from "#veryfront/errors/veryfront-error.ts";

describe("model-catalog", () => {
it("resolves known model IDs to HuggingFace IDs", () => {
Expand Down Expand Up @@ -53,6 +55,49 @@ describe("ai-sdk-adapter", () => {
// deno-lint-ignore no-explicit-any
assertEquals((model as any).modelId, "local/smollm2-135m");
});

it("sets _isVfLocalModel marker for ensureModelReady detection", () => {
const model = createLocalModel("smollm2-135m");
const m = model as Record<string, unknown>;
assertEquals(m._isVfLocalModel, true);
});
});

describe("ensureModelReady", () => {
afterEach(() => {
clearModelProviders();
});

it("is a no-op for non-local models (no _isVfLocalModel marker)", async () => {
// A mock model without _isVfLocalModel should pass through immediately
const mockModel = {
specificationVersion: "v2" as const,
provider: "openai",
modelId: "openai/gpt-4o",
supportedUrls: {},
doGenerate: async () => ({}),
doStream: async () => ({ stream: new ReadableStream() }),
};
// Should not throw — just returns without verifying runtime
// deno-lint-ignore no-explicit-any
await ensureModelReady(mockModel as any);
});

it("throws no_ai_available for local models when runtime unavailable", async () => {
const prev = Deno.env.get("VERYFRONT_DISABLE_LOCAL_AI");
Deno.env.set("VERYFRONT_DISABLE_LOCAL_AI", "1");
try {
const localModel = createLocalModel("smollm2-135m");
const error = await assertRejects(
() => ensureModelReady(localModel),
);
const vfError = fromError(error);
assertEquals(vfError?.type, "no_ai_available");
} finally {
if (prev === undefined) Deno.env.delete("VERYFRONT_DISABLE_LOCAL_AI");
else Deno.env.set("VERYFRONT_DISABLE_LOCAL_AI", prev);
}
});
});

describe("local-engine (requires model download)", {
Expand Down
24 changes: 24 additions & 0 deletions src/provider/model-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { serverLogger } from "#veryfront/utils";
import { DEFAULT_LOCAL_MODEL } from "./local/model-catalog.ts";
import { createLocalModel } from "./local/ai-sdk-adapter.ts";
import { isLocalAIDisabled } from "./local/env.ts";
import { verifyLocalRuntime } from "./local/local-engine.ts";

const localLogger = serverLogger.component("local-llm");

Expand Down Expand Up @@ -227,6 +228,29 @@ export function getRegisteredModelProviders(): string[] {
return manager.getAllIds();
}

/**
* Eagerly verify that the resolved model's runtime is available.
*
* For real local-engine models (created by `createLocalModel()`) this
* eagerly loads the ONNX pipeline to surface `no_ai_available` errors
* **before** the HTTP response stream is created. Must happen before the
* ReadableStream so the chat handler can return a proper 503 (with
* browser-fallback info) rather than a 200 with an in-band SSE error.
*
* Uses the `_isVfLocalModel` marker set by `createLocalModel()` to
* distinguish real local-engine models from mock/custom providers that
* happen to use `provider: "local"`.
*/
export async function ensureModelReady(
model: LanguageModel,
): Promise<void> {
const m = model as Record<string, unknown>;
if (!m._isVfLocalModel) return;
// modelId is "local/<id>" — strip the prefix to get the catalog id.
const catalogId = typeof m.modelId === "string" ? m.modelId.replace(/^local\//, "") : undefined;
await verifyLocalRuntime(catalogId);
}

/**
* Clear all registered model providers (for testing).
*/
Expand Down