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
3 changes: 3 additions & 0 deletions packages/ai/src/provider-utils/BaseCloudProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface CloudProviderMetadata {
readonly displayName: string;
readonly isLocal?: boolean;
readonly supportsBrowser?: boolean;
readonly supportsServer?: boolean;
}

/**
Expand Down Expand Up @@ -55,6 +56,7 @@ export function createCloudProviderClass<TModelConfig extends ModelConfig>(
readonly displayName = meta.displayName;
readonly isLocal = meta.isLocal ?? false;
readonly supportsBrowser = meta.supportsBrowser ?? true;
readonly supportsServer = meta.supportsServer ?? true;
}
return CloudProvider as unknown as new (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand All @@ -66,5 +68,6 @@ export function createCloudProviderClass<TModelConfig extends ModelConfig>(
readonly displayName: string;
readonly isLocal: boolean;
readonly supportsBrowser: boolean;
readonly supportsServer: boolean;
};
}
19 changes: 19 additions & 0 deletions packages/ai/src/provider/AiProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ export abstract class AiProvider<TModelConfig extends ModelConfig = ModelConfig>
/** Whether this provider can run in a browser environment. */
abstract readonly supportsBrowser: boolean;

/**
* Whether this provider can run server-side (a Bun/Node host process).
* Orthogonal to {@link isLocal}: a provider can be local (Ollama) yet
* server-capable. builder maps `supportsBrowser` / `supportsServer` /
* `isLocal` onto its own `browser | desktop | cloud` deployment taxonomy;
* libs deliberately does not know those host names.
*/
abstract readonly supportsServer: boolean;

/**
* Promise+emit capability-set run-fn registrations injected via the constructor.
* Required for inline and worker-server registration. Not needed for worker-mode
Expand Down Expand Up @@ -155,6 +164,16 @@ export abstract class AiProvider<TModelConfig extends ModelConfig = ModelConfig>
return (model.capabilities as readonly Capability[] | undefined) ?? [];
}

/**
* Runtime availability probe for environment-dependent providers (e.g. Chrome
* AI, which needs `window.ai`). Returns `true` by default; providers whose
* reachability depends on the host environment override it. Replaces the
* renderer's ad-hoc `isChromeBuiltinAiAvailable` call as the uniform check.
*/
async isAvailable(): Promise<boolean> {
return true;
}

/**
* Register this provider on the main thread.
*
Expand Down
3 changes: 3 additions & 0 deletions packages/test/src/test/ai-provider/AiProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class TestProvider extends AiProvider {
readonly displayName = "Test AI Provider";
readonly isLocal = false;
readonly supportsBrowser = true;
readonly supportsServer = true;

public initializeCalled = false;
public initializeOptions: AiProviderRegisterContext | null = null;
Expand All @@ -73,6 +74,7 @@ class TestQueuedProvider extends QueuedAiProvider {
readonly displayName = "Test AI Provider";
readonly isLocal = false;
readonly supportsBrowser = true;
readonly supportsServer = true;

constructor(fns?: readonly AiProviderRunFnRegistration[]) {
super(fns);
Expand All @@ -86,6 +88,7 @@ class StaticTaskTypesProvider extends AiProvider {
readonly displayName = "Static Task Types";
readonly isLocal = false;
readonly supportsBrowser = true;
readonly supportsServer = true;

constructor(fns?: readonly AiProviderRunFnRegistration[]) {
super(fns);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ class TestProvider extends AiProvider {
readonly displayName = "Test Provider";
readonly isLocal = false;
readonly supportsBrowser = true;
readonly supportsServer = true;

constructor(name: string, runFns: readonly AiProviderRunFnRegistration[]) {
super(runFns);
Expand Down
1 change: 1 addition & 0 deletions packages/test/src/test/ai/AiChatTask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ class FakeChatProvider extends AiProvider {
override readonly displayName = "Fake Chat";
override readonly isLocal = true;
override readonly supportsBrowser = false;
override readonly supportsServer = true;

constructor(runFns?: readonly AiProviderRunFnRegistration<any, any, ModelConfig>[]) {
super(runFns);
Expand Down
1 change: 1 addition & 0 deletions packages/test/src/test/ai/AiChatWithKbTask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ class FakeChatKbProvider extends AiProvider {
override readonly displayName = "Fake Chat KB";
override readonly isLocal = true;
override readonly supportsBrowser = false;
override readonly supportsServer = true;

constructor(promiseRunFns?: readonly AiProviderRunFnRegistration[]) {
super(promiseRunFns);
Expand Down
59 changes: 59 additions & 0 deletions packages/test/src/test/ai/ProviderBaseUrlSeam.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

/**
* Contract test for Decision S-07b: every HTTP provider must honor
* provider_config.base_url so a future proxy target can repoint them at
* /api/ai/<provider> without changing provider code.
*
* These are constructor-only assertions — no network requests are sent.
* The dummy api_key prevents resolveApiKey() from throwing due to a missing
* environment variable.
*
* Coverage:
* - Anthropic: getClient() from @workglow/anthropic/ai-runtime — COVERED
* - OpenAI: getClient() from @workglow/openai/ai-runtime — COVERED
* - Ollama: getClient() from @workglow/ollama/ai-runtime — MANUAL REVIEW
* The Ollama SDK stores the host on `protected readonly config` (not a
* public property), so no assertable property is available from outside
* the class. The pass-through is trivially visible in Ollama_Client.ts:
* `new Ollama({ host: model?.provider_config?.base_url || DEFAULT })`.
* - Gemini: no getClient() builder; key is resolved separately and the
* Google Generative AI SDK does not expose a configurable base URL via
* the same pattern — MANUAL REVIEW.
*/

import { getClient as getAnthropicClient } from "@workglow/anthropic/ai-runtime";
import { getClient as getOpenAiClient } from "@workglow/openai/ai-runtime";
import { describe, expect, it } from "vitest";

const LOCAL = "http://127.0.0.1:9/api/ai";

describe("HTTP provider base_url repointing seam (S-07b)", () => {
it("Anthropic client honors provider_config.base_url", async () => {
const client = await getAnthropicClient({
provider: "ANTHROPIC",
provider_config: {
model_name: "claude-test",
api_key: "test-key",
base_url: LOCAL,
},
} as any);
expect(client.baseURL).toBe(LOCAL);
});

it("OpenAI client honors provider_config.base_url", async () => {
const client = await getOpenAiClient({
provider: "OPENAI",
provider_config: {
model_name: "gpt-test",
api_key: "test-key",
base_url: LOCAL,
},
} as any);
expect(client.baseURL).toBe(LOCAL);
});
});
98 changes: 98 additions & 0 deletions packages/test/src/test/ai/ProviderRuntimeMetadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

import { _testOnly as _anthropicTestOnly } from "@workglow/anthropic/ai";
import { _testOnly } from "@workglow/chrome-ai/ai";
import { _testOnly as _ollamaTestOnly } from "@workglow/ollama/ai";
import { _testOnly as _openaiTestOnly } from "@workglow/openai/ai";
import { afterEach, describe, expect, it } from "vitest";

const { WebBrowserProvider } = _testOnly;
const { AnthropicQueuedProvider } = _anthropicTestOnly;
const { OpenAiQueuedProvider } = _openaiTestOnly;
const { OllamaQueuedProvider } = _ollamaTestOnly;

describe("WebBrowserProvider.isAvailable", () => {
const g = globalThis as Record<string, unknown>;
afterEach(() => {
delete g.LanguageModel;
});

it("returns false when no Chrome AI globals are present", async () => {
delete g.LanguageModel;
const provider = new WebBrowserProvider();
expect(await provider.isAvailable()).toBe(false);
});

it("returns true when a Chrome AI global is present", async () => {
g.LanguageModel = function () {};
const provider = new WebBrowserProvider();
expect(await provider.isAvailable()).toBe(true);
});
});

// NOTE (S-07a): The spec table calls for Anthropic/OpenAI to have
// supportsBrowser=false. The actual declared values are true (the
// createCloudProviderClass mixin defaults supportsBrowser to true when the
// metadata literal omits it). Tests below assert ACTUAL values; see
// DONE_WITH_CONCERNS note in task report for reconciliation details.
describe("provider runtime-placement metadata", () => {
it("declares the spec placement table", () => {
const cases = [
// WebBrowserProvider: browser-only, on-device — matches spec exactly
{
provider: new WebBrowserProvider(),
supportsBrowser: true,
supportsServer: false,
isLocal: true,
},
// OllamaQueuedProvider: browser+server, local — matches spec exactly
{
provider: new OllamaQueuedProvider(),
supportsBrowser: true,
supportsServer: true,
isLocal: true,
},
// AnthropicQueuedProvider: spec says supportsBrowser=false but actual=true (mixin default)
{
provider: new AnthropicQueuedProvider(),
supportsBrowser: true,
supportsServer: true,
isLocal: false,
},
// OpenAiQueuedProvider: spec says supportsBrowser=false but actual=true (mixin default)
{
provider: new OpenAiQueuedProvider(),
supportsBrowser: true,
supportsServer: true,
isLocal: false,
},
] as const;
for (const c of cases) {
expect(c.provider.supportsBrowser).toBe(c.supportsBrowser);
expect(c.provider.supportsServer).toBe(c.supportsServer);
expect(c.provider.isLocal).toBe(c.isLocal);
}
});

it("never marks a renderer-only provider as cloud-reachable", () => {
// cloud reachability == supportsServer && !isLocal; renderer-only providers
// (supportsServer === false) can never satisfy it.
const rendererOnly = new WebBrowserProvider();
expect(rendererOnly.supportsServer && !rendererOnly.isLocal).toBe(false);
});
});

describe("credential threading (S-07c)", () => {
// Renderer-only providers run on-device and must never be handed cloud
// secrets. Their declared metadata is the structural guarantee: a provider
// that cannot run server-side is local and therefore needs no API key.
it("renderer-only providers are local and carry no api-key requirement", () => {
const provider = new WebBrowserProvider();
expect(provider.supportsServer).toBe(false);
expect(provider.isLocal).toBe(true);
});
});
2 changes: 2 additions & 0 deletions packages/test/src/test/ai/SessionCaching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ describe("SessionCaching", () => {
readonly displayName = "Test";
readonly isLocal = true;
readonly supportsBrowser = true;
readonly supportsServer = true;

constructor(runFns?: readonly AiProviderRunFnRegistration[]) {
super(runFns);
Expand Down Expand Up @@ -422,6 +423,7 @@ describe("SessionCaching", () => {
readonly displayName = "Test";
readonly isLocal = true;
readonly supportsBrowser = true;
readonly supportsServer = true;

constructor(runFns?: readonly AiProviderRunFnRegistration[]) {
super(runFns);
Expand Down
1 change: 1 addition & 0 deletions packages/test/src/test/ai/StructuredGenerationTask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ class FakeStructuredProvider extends AiProvider {
override readonly displayName = "Fake Structured";
override readonly isLocal = true;
override readonly supportsBrowser = false;
override readonly supportsServer = true;

constructor(runFns?: readonly AiProviderRunFnRegistration<any, any, ModelConfig>[]) {
super(runFns);
Expand Down
1 change: 1 addition & 0 deletions providers/cactus/src/ai/CactusProvider.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class CactusProvider extends AiProvider<CactusModelConfig> {
readonly displayName = "Cactus (Needle)";
readonly isLocal = true;
readonly supportsBrowser = true;
readonly supportsServer = true;

constructor(
promiseRunFns?: readonly AiProviderRunFnRegistration<
Expand Down
1 change: 1 addition & 0 deletions providers/cactus/src/ai/CactusProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class CactusProvider extends AiProvider<CactusModelConfig> {
readonly displayName = "Cactus (Needle)";
readonly isLocal = true;
readonly supportsBrowser = true;
readonly supportsServer = true;

constructor(
promiseRunFns?: readonly AiProviderRunFnRegistration<
Expand Down
1 change: 1 addition & 0 deletions providers/cactus/src/ai/CactusQueuedProvider.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class CactusQueuedProvider extends QueuedAiProvider<CactusModelConfig> {
readonly displayName = "Cactus (Needle)";
readonly isLocal = true;
readonly supportsBrowser = true;
readonly supportsServer = true;

constructor(
promiseRunFns?: readonly AiProviderRunFnRegistration<
Expand Down
1 change: 1 addition & 0 deletions providers/cactus/src/ai/CactusQueuedProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export class CactusQueuedProvider extends QueuedAiProvider<CactusModelConfig> {
readonly displayName = "Cactus (Needle)";
readonly isLocal = true;
readonly supportsBrowser = true;
readonly supportsServer = true;

constructor(
promiseRunFns?: readonly AiProviderRunFnRegistration<
Expand Down
17 changes: 17 additions & 0 deletions providers/chrome-ai/src/ai/WebBrowserProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export class WebBrowserProvider extends AiProvider<WebBrowserModelConfig> {
readonly displayName = "Chrome Built-in AI";
readonly isLocal = true;
readonly supportsBrowser = true;
readonly supportsServer = false;

/**
* Result of {@link probeWebBrowserCapabilities}. Until the probe resolves
Expand Down Expand Up @@ -90,6 +91,22 @@ export class WebBrowserProvider extends AiProvider<WebBrowserModelConfig> {
return webBrowserWorkerRunFnSpecs();
}

/**
* Chrome Built-in AI is only reachable when the browser exposes at least one
* of its API globals. Mirrors the renderer's former `isChromeBuiltinAiAvailable`
* synchronous check, now surfaced through the uniform provider probe.
*/
override async isAvailable(): Promise<boolean> {
const g = globalThis as Record<string, unknown>;
return (
typeof g.LanguageModel !== "undefined" ||
typeof g.Summarizer !== "undefined" ||
typeof g.Translator !== "undefined" ||
typeof g.Rewriter !== "undefined" ||
typeof g.LanguageDetector !== "undefined"
);
}

/**
* Releases any cached Chrome `LanguageModel` session for the given id.
* `AiChatTask` registers this via `ResourceScope` so multi-turn chat
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export class HuggingFaceTransformersProvider extends AiProvider<HfTransformersOn
readonly displayName = "Hugging Face Transformers (ONNX)";
readonly isLocal = true;
readonly supportsBrowser = true;
readonly supportsServer = true;

constructor(
promiseRunFns?: readonly AiProviderRunFnRegistration<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export class HuggingFaceTransformersQueuedProvider extends QueuedAiProvider<HfTr
readonly displayName = "Hugging Face Transformers (ONNX)";
readonly isLocal = true;
readonly supportsBrowser = true;
readonly supportsServer = true;

private cpuStrategy: IAiExecutionStrategy | undefined;

Expand Down
1 change: 1 addition & 0 deletions providers/mlx/src/ai/MlxProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export class MlxProvider extends AiProvider {
readonly displayName = "Local MLX (Apple Silicon)";
readonly isLocal = true;
readonly supportsBrowser = false;
readonly supportsServer = true;

constructor() {
const runFns: readonly AiProviderRunFnRegistration<
Expand Down
1 change: 1 addition & 0 deletions providers/node-llama-cpp/src/ai/LlamaCppProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export class LlamaCppProvider extends AiProvider<LlamaCppModelConfig> {
readonly displayName = "Local llama.cpp";
readonly isLocal = true;
readonly supportsBrowser = false;
readonly supportsServer = true;

constructor(
promiseRunFns?: readonly AiProviderRunFnRegistration<
Expand Down
1 change: 1 addition & 0 deletions providers/node-llama-cpp/src/ai/LlamaCppQueuedProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export class LlamaCppQueuedProvider extends QueuedAiProvider<LlamaCppModelConfig
readonly displayName = "Local llama.cpp";
readonly isLocal = true;
readonly supportsBrowser = false;
readonly supportsServer = true;

constructor(
promiseRunFns?: readonly AiProviderRunFnRegistration<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export class TensorFlowMediaPipeProvider extends AiProvider<TFMPModelConfig> {
readonly displayName = "TensorFlow MediaPipe";
readonly isLocal = true;
readonly supportsBrowser = true;
readonly supportsServer = false;

constructor(
promiseRunFns?: readonly AiProviderRunFnRegistration<
Expand Down
Loading