From 2fff7d684cf98e7e1e1c168797d7760950a43dda Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Tue, 29 Sep 2026 02:04:42 +0300 Subject: [PATCH] LOU-K3: support a real LLM provider on Cloudflare Worker deploy target The cloudflare-worker deploy target only registered the 'mock' LLM provider, per runtime.worker.ts's own comment because "the real provider classes depend on optional-peer SDK packages that this bundle does not include" - not because of an actual Workers incompatibility. Verified both OpenAIProvider and AnthropicProvider (via @ai-sdk/openai and @ai-sdk/anthropic on top of the Vercel `ai` SDK's fetch()-based generateText/streamText) have zero `node:*` imports anywhere in their dependency graphs, so both are genuinely Workers-compatible. Wire both into runtime.worker.ts's LLMProviderRegistry and WORKER_SUPPORTED_PROVIDERS, add @ai-sdk/openai as a devDependency alongside the existing @ai-sdk/anthropic so both can be verified/bundled, and extend cloudflare.test.ts to prove scaffold+build succeeds and produces a zero-node:-references bundle for both, while a genuinely unsupported provider ('ollama', which defaults to an unreachable local endpoint) still fails closed with a clear error. The 'http' built-in tool stays unsupported on Workers: its SSRF protection depends on node:dns to resolve-then-verify every address before connecting (closing a DNS-rebinding gap) and a pinned undici Agent for per-request TLS settings, neither of which has a Workers-native fetch() equivalent - reimplementing it on plain fetch() would silently drop that protection rather than losing mere convenience, so it's left unsupported rather than shipped weaker under the same name. Documented in docs/deployment.md and runtime.worker.ts. Co-Authored-By: Claude Sonnet 5 --- docs/deployment.md | 30 ++++++--- package-lock.json | 84 ++++++++++++++++++++++++++ package.json | 1 + src/deploy/adapters/cloudflare.test.ts | 76 ++++++++++++++++++++++- src/deploy/adapters/cloudflare.ts | 13 +++- src/deploy/runtime.worker.ts | 35 +++++++++-- 6 files changed, 223 insertions(+), 16 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 6aedaaa69..f71c26ccc 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 `_API_KEY` -(`wrangler secret put OPENAI_API_KEY`). +the unsupported provider or tool. Provider API keys are read from Worker +bindings named `_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 diff --git a/package-lock.json b/package-lock.json index b4af80e98..52ad26e48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,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", @@ -147,6 +148,89 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/@ai-sdk/openai": { + "version": "0.0.42", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-0.0.42.tgz", + "integrity": "sha512-Z3UTpkYmMBgJn+1UPlcoGjF0+mx53JKjAkqDvf+o8N5wi1/lZxnDc1KXdut2j08mZhaMZ4QxzuidLDhAtUGb5g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "0.0.15", + "@ai-sdk/provider-utils": "1.0.7" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-0.0.15.tgz", + "integrity": "sha512-phX/YdwKd8q8/uZ7MsUytcHuN5KvT+wgM+y78eu6E+VyFE3GRwelctBFnaaA96uRL6xnKNmb0e7e+2fDOYuBoA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/openai/node_modules/@ai-sdk/provider-utils": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-1.0.7.tgz", + "integrity": "sha512-xIDpinTnuInH16wBgKAVdN6pmrpjlJF+5f+f/8ahYMQlYzpe4Vj1qw8OL+rUGdCePcRSrFmjk8G/wdX5+J8dIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "0.0.15", + "eventsource-parser": "1.1.2", + "nanoid": "3.3.6", + "secure-json-parse": "2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/openai/node_modules/eventsource-parser": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.1.2.tgz", + "integrity": "sha512-v0eOBUbiaFojBu2s2NPBfYUoRR9GjcDNvCXVaqEf5vVfpIAh9f8RCo4vXTP8c63QRKCFwoLpMpTdPwwhEKVgzA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@ai-sdk/openai/node_modules/nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/@ai-sdk/provider": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", diff --git a/package.json b/package.json index 83b23b615..99f73f9c8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/deploy/adapters/cloudflare.test.ts b/src/deploy/adapters/cloudflare.test.ts index 4b5ae71be..b93d95b1f 100644 --- a/src/deploy/adapters/cloudflare.test.ts +++ b/src/deploy/adapters/cloudflare.test.ts @@ -29,6 +29,7 @@ import { createRequire } from 'node:module'; import { CloudflareWorkerAdapter, WORKER_SIZE_LIMIT_BYTES, + WORKER_SUPPORTED_PROVIDERS, WORKER_SUPPORTED_TOOLS, findNodeBuiltinReferences, formatBundleSize, @@ -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 { @@ -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', () => { @@ -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) => Promise }; + 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 + ); + }); }); diff --git a/src/deploy/adapters/cloudflare.ts b/src/deploy/adapters/cloudflare.ts index 18632b4bf..a06a557eb 100644 --- a/src/deploy/adapters/cloudflare.ts +++ b/src/deploy/adapters/cloudflare.ts @@ -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'; diff --git a/src/deploy/runtime.worker.ts b/src/deploy/runtime.worker.ts index a9e52aea3..624a8a849 100644 --- a/src/deploy/runtime.worker.ts +++ b/src/deploy/runtime.worker.ts @@ -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 @@ -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'; @@ -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 = { 'current-date': currentDateTool, 'day-name': dayNameTool,