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
30 changes: 23 additions & 7 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,31 @@ npx wrangler deploy # requires a Cloudflare account (`wrangler login`)

Workers have no Node.js builtins, so this target currently supports:

- the `mock` provider (real provider SDKs are not bundled into the Worker
yet - use `node-server` or `docker` for OpenAI/Anthropic/Ollama/OpenRouter);
- the `current-date` and `day-name` tools (`http` depends on Node networking
modules).
- providers: `mock`, `openai` and `anthropic`. The `openai`/`anthropic`
providers are built on the Vercel `ai` SDK's `generateText`/`streamText`
plus `@ai-sdk/openai`/`@ai-sdk/anthropic`, which are pure
`fetch()`/Web-standard implementations with no `node:*` imports anywhere
in their dependency graph, so they bundle and run on Workers cleanly.
`ollama` and `openrouter` are **not** supported here - `ollama` defaults
to a local `http://localhost:11434` endpoint that a Worker can't reach,
and `openrouter` hasn't had a Workers-compatibility audit; use
`node-server` or `docker` for those;
- tools: `current-date` and `day-name`. `http` is **not** supported: its
SSRF protection resolves the hostname via `node:dns` and checks *every*
resolved address against a denylist before connecting (closing a
DNS-rebinding gap), then pins TLS settings per request via a dedicated
`undici` `Agent`. Workers' native `fetch()` has no equivalent hook to
resolve a hostname up front and pin the connection to the verified IP, so
a Workers version of this tool built on plain `fetch()` would silently
drop that protection rather than just losing convenience functionality -
it's left unsupported rather than shipped weaker under the same name.

`loushy build` rejects a spec that uses anything else, with an error naming
the unsupported provider or tool. Provider API keys, once real providers are
supported, are read from Worker bindings named `<TYPE>_API_KEY`
(`wrangler secret put OPENAI_API_KEY`).
the unsupported provider or tool. Provider API keys are read from Worker
bindings named `<TYPE>_API_KEY` (e.g. `wrangler secret put OPENAI_API_KEY`,
`wrangler secret put ANTHROPIC_API_KEY`) - the `openai`/`anthropic`
peer packages (`@ai-sdk/openai`/`@ai-sdk/anthropic`, `ai`) must be installed
alongside `@loushy/build-ai-agent` for `loushy build` to bundle them.

## Custom targets

Expand Down
84 changes: 84 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@
},
"devDependencies": {
"@ai-sdk/anthropic": "^0.0.42",
"@ai-sdk/openai": "^0.0.42",
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/sdk-trace-base": "^2.11.0",
"@opentelemetry/sdk-trace-node": "^2.11.0",
Expand Down
76 changes: 74 additions & 2 deletions src/deploy/adapters/cloudflare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { createRequire } from 'node:module';
import {
CloudflareWorkerAdapter,
WORKER_SIZE_LIMIT_BYTES,
WORKER_SUPPORTED_PROVIDERS,
WORKER_SUPPORTED_TOOLS,
findNodeBuiltinReferences,
formatBundleSize,
Expand All @@ -37,6 +38,8 @@ import {
wranglerTomlSource,
} from './cloudflare';
import { getAdapter, registerBuiltInAdapters } from '../index';
import { LLMProviderRegistry } from '../../providers/llm';
import { prepareWorkerSpec } from '../runtime.worker';

let wranglerBin: string | undefined;
try {
Expand Down Expand Up @@ -118,13 +121,40 @@ describe('CloudflareWorkerAdapter', () => {
await expect(
CloudflareWorkerAdapter.scaffold(writeSpec(dir, { ...SPEC, tools: ['http'] }), out)
).rejects.toThrow(/tool 'http' is not available on Cloudflare Workers/);
// 'ollama' stays unsupported (see runtime.worker.ts doc comment: it
// defaults to a local endpoint unreachable from a Worker) - 'openai'
// and 'anthropic' are now real, supported providers (LOU-K3).
await expect(
CloudflareWorkerAdapter.scaffold(
writeSpec(dir, { ...SPEC, provider: { type: 'openai', model: 'gpt-4o-mini' } }),
writeSpec(dir, { ...SPEC, provider: { type: 'ollama', model: 'llama3' } }),
out
)
).rejects.toThrow(/provider 'openai' is not supported by the cloudflare-worker target/);
).rejects.toThrow(/provider 'ollama' is not supported by the cloudflare-worker target/);
expect(WORKER_SUPPORTED_TOOLS).not.toContain('http');
expect(WORKER_SUPPORTED_PROVIDERS).toEqual(['mock', 'openai', 'anthropic']);
});

it("registers 'openai' and 'anthropic' in the Worker's LLMProviderRegistry (LOU-K3)", () => {
// Importing runtime.worker.ts (done at module load via the top-level
// import above) must have registered both without throwing, and
// constructing each provider (no network call happens until
// generate()/stream() is actually invoked) must succeed with an API
// key sourced the way prepareWorkerSpec reads it - from Worker `env`
// bindings, never process.env.
expect(LLMProviderRegistry.has('openai')).toBe(true);
expect(LLMProviderRegistry.has('anthropic')).toBe(true);

const openaiPrepared = prepareWorkerSpec(
{ ...SPEC, provider: { type: 'openai', model: 'gpt-4o-mini' } },
{ OPENAI_API_KEY: 'sk-test' }
);
expect(openaiPrepared.provider.name).toBe('openai');

const anthropicPrepared = prepareWorkerSpec(
{ ...SPEC, provider: { type: 'anthropic', model: 'claude-3-5-sonnet-latest' } },
{ ANTHROPIC_API_KEY: 'sk-ant-test' }
);
expect(anthropicPrepared.provider.name).toBe('anthropic');
});

describe('scaffold + build', () => {
Expand Down Expand Up @@ -317,4 +347,46 @@ describe('CloudflareWorkerAdapter', () => {
}, 150_000);
});
});

describe('scaffold + build with a real provider (LOU-K3)', () => {
it.each(['openai', 'anthropic'] as const)(
"scaffolds and builds a Worker bundle for provider '%s' with zero node: references",
async (providerType) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `loushy-cf-${providerType}-`));
const outDir = path.join(dir, 'out');
const model = providerType === 'openai' ? 'gpt-4o-mini' : 'claude-3-5-sonnet-latest';

await CloudflareWorkerAdapter.scaffold(
writeSpec(dir, { ...SPEC, provider: { type: providerType, model } }),
outDir
);
await CloudflareWorkerAdapter.build(outDir);

const bundlePath = path.join(outDir, 'dist', 'worker.js');
const bundle = fs.readFileSync(bundlePath, 'utf8');
expect(bundle.length).toBeGreaterThan(0);
expect(findNodeBuiltinReferences(bundle)).toEqual([]);
expect(bundle).not.toMatch(/node:/);

// Drive the real built bundle's fetch() handler end to end. The
// provider genuinely tries to call the real API (no network access
// in this sandbox / no real key), so assert it fails for a network
// reason - not because the provider is "unsupported" or missing
// from the Worker's registry, which is the thing LOU-K3 actually
// fixes. A registry/"not found" error would mean the wiring is
// broken; a network/auth error proves the provider was resolved
// and genuinely attempted a fetch()-based call.
const mod = await import(pathToFileURL(bundlePath).href);
const handler = mod.default as { fetch: (r: Request, env?: Record<string, unknown>) => Promise<Response> };
const chat = await handler.fetch(
new Request('http://worker/chat', { method: 'POST', body: JSON.stringify({ message: 'hi' }) }),
{ [`${providerType.toUpperCase()}_API_KEY`]: 'sk-test-not-a-real-key' }
);
const body = await chat.json();
expect(chat.status).toBe(500);
expect(String(body.error)).not.toMatch(/not found\. Available:|not supported by the cloudflare-worker target/);
},
30_000
);
});
});
13 changes: 11 additions & 2 deletions src/deploy/adapters/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,17 @@ import { agentConfigModuleSource, loadAgentSpecForDeploy } from './node-server';

/** Built-in tools that work without Node builtins (see runtime.worker.ts). */
export const WORKER_SUPPORTED_TOOLS = ['current-date', 'day-name'];
/** Provider types registered in the Worker bundle (see runtime.worker.ts). */
export const WORKER_SUPPORTED_PROVIDERS = ['mock'];
/**
* Provider types registered in the Worker bundle (see runtime.worker.ts).
*
* 'openai' and 'anthropic' (LOU-K3) are real, network-calling providers -
* both implemented on the Vercel `ai` SDK's fetch()-based
* generateText/streamText plus @ai-sdk/openai / @ai-sdk/anthropic, which
* have no `node:*` imports anywhere in their dependency graph and are
* genuinely Workers-compatible. 'ollama' and 'openrouter' remain
* unsupported here (see runtime.worker.ts's doc comment for why).
*/
export const WORKER_SUPPORTED_PROVIDERS = ['mock', 'openai', 'anthropic'];

/** Pinned so a given SDK version always generates the same, reproducible config. */
export const COMPATIBILITY_DATE = '2024-09-23';
Expand Down
35 changes: 30 additions & 5 deletions src/deploy/runtime.worker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Cloudflare Worker runtime for generated workers (LOU-I3).
* Cloudflare Worker runtime for generated workers (LOU-I3, LOU-K3).
*
* The worker.ts the cloudflare-worker adapter scaffolds imports
* `@loushy/build-ai-agent/deploy-runtime-worker`, resolved at build time to
Expand All @@ -14,13 +14,31 @@
* no-Node constraint:
* - Providers are created via LLMProviderRegistry with the API key read
* from the Worker's `env` bindings (Workers have no process.env), not
* via resolveProvider(). Only 'mock' is registered in the bundle -
* the real provider classes depend on optional-peer SDK packages that
* this bundle does not include.
* via resolveProvider(). Registered providers: 'mock', 'openai' and
* 'anthropic' (LOU-K3). Both real providers are implemented on top of
* the Vercel `ai` SDK's `generateText`/`streamText` plus
* `@ai-sdk/openai`/`@ai-sdk/anthropic` - verified (see LOU-K3 PR
* description) to be pure fetch()/Web-standard implementations with no
* `node:*` imports anywhere in their dependency graphs, so they bundle
* and run on Workers cleanly. 'ollama' and 'openrouter' are NOT
* registered here: ollama-ai-provider defaults to a local
* http://localhost:11434 endpoint unreachable from a Worker and isn't
* a realistic Workers target, and openrouter has had no Workers
* compatibility audit - both remain node-server/docker-only for now.
* - Only the built-in tools that need no Node builtins are available
* ('current-date', 'day-name'); 'http' uses node:net/node:dns/undici.
* ('current-date', 'day-name'); 'http' uses node:net/node:dns/undici
* for its SSRF denylist (DNS-rebinding-safe resolve-then-verify) and a
* pinned undici Agent/dispatcher for per-request TLS settings - neither
* has a Workers-native equivalent (fetch() gives no hook to resolve a
* hostname up front and pin the connection to the verified IP), so a
* Workers 'http' tool re-implemented on plain fetch() would silently
* drop that DNS-rebinding protection rather than just losing convenience
* functionality. Left unsupported here rather than shipping a weaker
* tool under the same name - see LOU-K3 PR description.
*/
import '../providers/mock';
import { OpenAIProvider, OpenAIProviderConfig } from '../providers/OpenAIProvider';
import { AnthropicProvider, AnthropicProviderConfig } from '../providers/AnthropicProvider';
import { LLMProvider, LLMProviderRegistry } from '../providers/llm';
import { currentDateTool } from '../tools/built-in/currentDate';
import { dayNameTool } from '../tools/built-in/dayName';
Expand All @@ -31,6 +49,13 @@ import { prepareSpecExecution, PreparedExecution } from './specExecution';
export { AgentExecutor } from '../execution/AgentExecutor';
export { agentSpecSchema } from '../spec/schema';

// Registered directly here (rather than via the '../providers' barrel,
// which also eagerly imports OllamaProvider/OpenRouterProvider and their
// optional peer SDKs) so the Worker bundle only pulls in the two providers
// actually supported on Workers - see the module doc comment above.
LLMProviderRegistry.register('openai', (config) => new OpenAIProvider(config as OpenAIProviderConfig));
LLMProviderRegistry.register('anthropic', (config) => new AnthropicProvider(config as AnthropicProviderConfig));

const WORKER_TOOLS: Record<string, ToolDescriptor> = {
'current-date': currentDateTool,
'day-name': dayNameTool,
Expand Down
Loading