diff --git a/README.md b/README.md index 58eea7b31..0a766db9e 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ cleans up after itself. the models included in your subscription. 2. **API keys (BYOK).** Paste a key from OpenRouter, Anthropic, OpenAI, xAI, Google Gemini, Amazon Bedrock, Vercel AI Gateway, + Cloudflare AI Gateway, Cloudflare Workers AI, Baseten, Together AI, Moonshot AI (Kimi), Kimi for Coding, MiniMax, Z.AI (including Coding Plan), OpenCode Zen / Go, or GitHub Copilot. @@ -232,7 +233,8 @@ it runs. **What models does it support?** Two options. Connect your ChatGPT Plus or Pro subscription directly (no API key needed), or paste an API key from OpenRouter, Anthropic, OpenAI, xAI, Google -Gemini, Amazon Bedrock, Vercel AI Gateway, Baseten, +Gemini, Amazon Bedrock, Vercel AI Gateway, Cloudflare AI Gateway, +Cloudflare Workers AI, Baseten, Together AI, Moonshot AI (Kimi), Kimi for Coding, MiniMax, Z.AI (including Coding Plan), OpenCode Zen / Go, or GitHub Copilot. diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 92530e03e..9171f0f1d 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -396,11 +396,19 @@ model. When unset, exploration falls back to the task's active coding model: R_EXPLORE_MODEL=openrouter/openai/gpt-5.6-luna ``` -The provider is the first segment of the model id. Roomote forwards these -common provider keys into worker containers: +The provider is the first segment of the model id. Configure these common +provider keys on the Roomote control plane. When the inference gateway is +enabled, API tokens stay on the control plane and sandboxes authenticate with +a run token. Non-secret identity values such as account IDs, gateway IDs, and +regions remain available to the task runtime: - `OPENROUTER_API_KEY` - `AI_GATEWAY_API_KEY` (Vercel AI Gateway, `vercel/...` models) +- `CLOUDFLARE_AI_GATEWAY_API_TOKEN`, `CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID`, + and `CLOUDFLARE_AI_GATEWAY_ID` (Cloudflare AI Gateway, + `cloudflare-ai-gateway/...` models) +- `CLOUDFLARE_WORKERS_AI_API_TOKEN` and `CLOUDFLARE_WORKERS_AI_ACCOUNT_ID` + (Cloudflare Workers AI, `cloudflare-workers-ai/...` models) - `OPENAI_API_KEY` - `ANTHROPIC_API_KEY` - `MOONSHOT_API_KEY` @@ -421,10 +429,11 @@ R_MODEL_ENV_KEYS=CUSTOM_PROVIDER_API_KEY CUSTOM_PROVIDER_API_KEY=... ``` -The checked-in Compose files forward the common provider keys above and the -sample `CUSTOM_PROVIDER_API_KEY`. If you use a different custom provider key -name in a Compose deployment, add that key to the service environment block or -provide it through your deployment secret mechanism. +The checked-in Compose files accept the common provider keys above and the +sample `CUSTOM_PROVIDER_API_KEY` on the control-plane services. If you use a +different custom provider key name in a Compose deployment, add that key to +the service environment block or provide it through your deployment secret +mechanism. ## Artifact Storage diff --git a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts index fc9a5f6b2..3250f3cba 100644 --- a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts +++ b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts @@ -116,6 +116,8 @@ describe('inference gateway', () => { vi.clearAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); + delete process.env.AWS_BEARER_TOKEN_BEDROCK; + delete process.env.AWS_REGION; mockFindTaskRun.mockResolvedValue({ id: 42 }); mockGetGitHubCopilotAccessToken.mockResolvedValue(null); mockGetFreshXaiAccessToken.mockResolvedValue(null); @@ -1311,4 +1313,212 @@ describe('inference gateway', () => { expect(response.status).toBe(405); expect(fetchMock).not.toHaveBeenCalled(); }); + + it('proxies Cloudflare AI Gateway with account URL and required gateway header', async () => { + mockResolveModelProviderEnvValue.mockImplementation( + async (names: string | readonly string[]) => { + const nameList = typeof names === 'string' ? [names] : names; + if (nameList.includes('CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID')) { + return 'a1b2c3d4e5f6789012345678abcdef90'; + } + if (nameList.includes('CLOUDFLARE_AI_GATEWAY_ID')) { + return 'default'; + } + return 'provider-secret-key'; + }, + ); + const fetchMock = stubUpstreamFetch(); + + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/cloudflare-ai-gateway/v1/chat/completions', + ); + + expect(response.status).toBe(200); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + 'https://api.cloudflare.com/client/v4/accounts/a1b2c3d4e5f6789012345678abcdef90/ai/v1/chat/completions', + ); + const headers = new Headers(init.headers); + expect(headers.get('authorization')).toBe('Bearer provider-secret-key'); + expect(headers.get('cf-aig-gateway-id')).toBe('default'); + }); + + it('proxies Cloudflare Workers AI with account URL and no gateway header', async () => { + mockResolveModelProviderEnvValue.mockImplementation( + async (names: string | readonly string[]) => { + const nameList = typeof names === 'string' ? [names] : names; + if (nameList.includes('CLOUDFLARE_WORKERS_AI_ACCOUNT_ID')) { + return 'a1b2c3d4e5f6789012345678abcdef90'; + } + return 'provider-secret-key'; + }, + ); + const fetchMock = stubUpstreamFetch(); + + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/cloudflare-workers-ai/v1/chat/completions', + ); + + expect(response.status).toBe(200); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + 'https://api.cloudflare.com/client/v4/accounts/a1b2c3d4e5f6789012345678abcdef90/ai/v1/chat/completions', + ); + const headers = new Headers(init.headers); + expect(headers.get('authorization')).toBe('Bearer provider-secret-key'); + expect(headers.get('cf-aig-gateway-id')).toBeNull(); + }); + + it('proxies Cloudflare Workers AI responses without a gateway id', async () => { + mockResolveModelProviderEnvValue.mockImplementation( + async (names: string | readonly string[]) => { + const nameList = typeof names === 'string' ? [names] : names; + if (nameList.includes('CLOUDFLARE_WORKERS_AI_ACCOUNT_ID')) { + return 'a1b2c3d4e5f6789012345678abcdef90'; + } + return 'provider-secret-key'; + }, + ); + const fetchMock = stubUpstreamFetch(); + + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/cloudflare-workers-ai/v1/responses', + ); + + expect(response.status).toBe(200); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + 'https://api.cloudflare.com/client/v4/accounts/a1b2c3d4e5f6789012345678abcdef90/ai/v1/responses', + ); + expect(new Headers(init.headers).get('cf-aig-gateway-id')).toBeNull(); + }); + + it('proxies Cloudflare Workers AI embeddings without a gateway id', async () => { + mockResolveModelProviderEnvValue.mockImplementation( + async (names: string | readonly string[]) => { + const nameList = typeof names === 'string' ? [names] : names; + if (nameList.includes('CLOUDFLARE_WORKERS_AI_ACCOUNT_ID')) { + return 'a1b2c3d4e5f6789012345678abcdef90'; + } + return 'provider-secret-key'; + }, + ); + const fetchMock = stubUpstreamFetch(); + + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/cloudflare-workers-ai/v1/embeddings', + ); + + expect(response.status).toBe(200); + const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + 'https://api.cloudflare.com/client/v4/accounts/a1b2c3d4e5f6789012345678abcdef90/ai/v1/embeddings', + ); + }); + + it('accepts an underscore Cloudflare AI Gateway id', async () => { + mockResolveModelProviderEnvValue.mockImplementation( + async (names: string | readonly string[]) => { + const nameList = typeof names === 'string' ? [names] : names; + if (nameList.includes('CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID')) { + return 'a1b2c3d4e5f6789012345678abcdef90'; + } + if (nameList.includes('CLOUDFLARE_AI_GATEWAY_ID')) { + return 'my_gateway'; + } + return 'provider-secret-key'; + }, + ); + const fetchMock = stubUpstreamFetch(); + + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/cloudflare-ai-gateway/v1/chat/completions', + ); + + expect(response.status).toBe(200); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(new Headers(init.headers).get('cf-aig-gateway-id')).toBe( + 'my_gateway', + ); + }); + + it('rewrites workers-ai/@cf model ids before forwarding AI Gateway requests', async () => { + mockResolveModelProviderEnvValue.mockImplementation( + async (names: string | readonly string[]) => { + const nameList = typeof names === 'string' ? [names] : names; + if (nameList.includes('CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID')) { + return 'a1b2c3d4e5f6789012345678abcdef90'; + } + if (nameList.includes('CLOUDFLARE_AI_GATEWAY_ID')) { + return 'default'; + } + return 'provider-secret-key'; + }, + ); + const fetchMock = stubUpstreamFetch(); + + const response = await appRequest( + createApp(createRunToken()), + '/api/inference/cloudflare-ai-gateway/v1/chat/completions', + { + model: 'workers-ai/@cf/zai-org/glm-5.2', + messages: [{ role: 'user', content: 'hi' }], + }, + ); + + expect(response.status).toBe(200); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(String(init.body))).toMatchObject({ + model: '@cf/zai-org/glm-5.2', + }); + }); + + it('fails closed when the AI Gateway id is missing', async () => { + mockResolveModelProviderEnvValue.mockImplementation( + async (names: string | readonly string[]) => { + const nameList = typeof names === 'string' ? [names] : names; + if (nameList.includes('CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID')) { + return 'a1b2c3d4e5f6789012345678abcdef90'; + } + if (nameList.includes('CLOUDFLARE_AI_GATEWAY_ID')) { + return undefined; + } + return 'provider-secret-key'; + }, + ); + const fetchMock = stubUpstreamFetch(); + + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/cloudflare-ai-gateway/v1/chat/completions', + ); + + expect(response.status).toBe(500); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'cloudflare-ai-gateway', + '/api/inference/cloudflare-ai-gateway/accounts/a1b2c3d4e5f6789012345678abcdef90/tokens', + ], + [ + 'cloudflare-workers-ai', + '/api/inference/cloudflare-workers-ai/accounts/a1b2c3d4e5f6789012345678abcdef90/ai/run', + ], + ] as const)( + 'rejects %s account-admin and non-inference paths', + async (_providerId, path) => { + const fetchMock = stubUpstreamFetch(); + const response = await postMessages(createApp(createRunToken()), path); + + expect(response.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); }); diff --git a/apps/api/src/handlers/inference/index.ts b/apps/api/src/handlers/inference/index.ts index 087ceb90d..9b2a6e314 100644 --- a/apps/api/src/handlers/inference/index.ts +++ b/apps/api/src/handlers/inference/index.ts @@ -1,6 +1,9 @@ import { Hono } from 'hono'; -import { formatSingleLineLog } from '@roomote/types'; +import { + formatSingleLineLog, + rewriteCloudflareAiGatewayRequestBody, +} from '@roomote/types'; import { db, eq, taskRuns } from '@roomote/db/server'; import { recordLlmUsage } from '@roomote/sdk/server'; @@ -381,6 +384,12 @@ inference.on(['POST', 'GET'], '/:provider/*', async (c) => { } } + if (providerId === 'cloudflare-ai-gateway' && method === 'POST') { + const bodyText = await c.req.text(); + requestBody = rewriteCloudflareAiGatewayRequestBody(bodyText); + useDuplexHalf = false; + } + try { const upstreamResponse = await fetchWithLongLivedStreamDispatcher( upstreamUrl, diff --git a/apps/api/src/handlers/inference/registry.ts b/apps/api/src/handlers/inference/registry.ts index 27e8808c7..e417640e1 100644 --- a/apps/api/src/handlers/inference/registry.ts +++ b/apps/api/src/handlers/inference/registry.ts @@ -1,6 +1,7 @@ import { CHATGPT_ACCOUNT_ID_HEADER, getInferenceGatewayProvider, + INFERENCE_GATEWAY_IDENTITY_PATTERN, INFERENCE_GATEWAY_RESOURCE_PATTERN, INFERENCE_GATEWAY_REGION_PATTERN, type InferenceGatewayProvider, @@ -79,23 +80,57 @@ export async function resolveGatewayUpstream( }; } + const requiredHeaders = await resolveRequiredForwardHeaders(provider); + return { ok: true, resolved: { upstreamUrl: `${upstreamBaseUrl}${upstreamPath}${search}`, - headers: - apiKey && provider.authHeader + headers: { + ...requiredHeaders, + ...(apiKey && provider.authHeader ? { [provider.authHeader.name]: formatProviderAuthHeaderValue( provider, apiKey, ), } - : {}, + : {}), + }, }, }; } +async function resolveRequiredForwardHeaders( + provider: InferenceGatewayProvider, +): Promise> { + if (!provider.requiredHeaders?.length) { + return {}; + } + + const headers: Record = {}; + + for (const spec of provider.requiredHeaders) { + const value = await resolveModelProviderEnvValue([spec.envVarName]); + + if (!value) { + throw new Error( + `${spec.envVarName} must be configured for ${provider.name}.`, + ); + } + + if (!INFERENCE_GATEWAY_IDENTITY_PATTERN.test(value)) { + throw new Error( + `${spec.envVarName} must be a valid identity value for ${provider.name}. Received "${value}".`, + ); + } + + headers[spec.headerName] = value; + } + + return headers; +} + /** * xAI supports both SuperGrok OAuth and a BYOK API key. Prefer a connected * subscription (fresh access token) so subscription users never need a key; diff --git a/apps/docs/docs.json b/apps/docs/docs.json index dbf57acbf..14dad81aa 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -74,6 +74,8 @@ "providers/inference/azure-openai", "providers/inference/baseten", "providers/inference/chatgpt", + "providers/inference/cloudflare-ai-gateway", + "providers/inference/cloudflare-workers-ai", "providers/inference/github-copilot", "providers/inference/google-gemini", "providers/inference/kimi-for-coding", diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index eadbe990d..6f290b465 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -188,6 +188,11 @@ as per-task auth tokens or workspace paths. | `AI_GATEWAY_API_KEY` | Provider key | Vercel AI Gateway API key. | | `BASETEN_API_KEY` | Provider key | Baseten API key. | | `TOGETHER_API_KEY` | Provider key | Together AI API key. | +| `CLOUDFLARE_AI_GATEWAY_API_TOKEN` | Provider key | Cloudflare AI Gateway API token. Does not connect Workers AI. | +| `CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID` | Provider config | Cloudflare account ID for AI Gateway requests. | +| `CLOUDFLARE_AI_GATEWAY_ID` | Provider config | Cloudflare AI Gateway ID, for example `default`. | +| `CLOUDFLARE_WORKERS_AI_API_TOKEN` | Provider key | Cloudflare Workers AI API token. Does not connect AI Gateway. | +| `CLOUDFLARE_WORKERS_AI_ACCOUNT_ID` | Provider config | Cloudflare account ID for Workers AI requests. A gateway ID is not used. | | `OPENAI_API_KEY` | Provider key | OpenAI API key. | | `AZURE_API_KEY` | Provider key | Azure OpenAI API key. | | `AZURE_RESOURCE_NAME` | Provider config | Azure OpenAI resource name, without the domain or URL. | diff --git a/apps/docs/models.mdx b/apps/docs/models.mdx index 7b0445bbb..3058a229e 100644 --- a/apps/docs/models.mdx +++ b/apps/docs/models.mdx @@ -56,6 +56,8 @@ These connections use metered API billing or a provider-managed gateway: | [Azure AI Foundry](/providers/inference/azure-foundry) | Azure AI Services API key and resource name | Azure subscription | | [Azure OpenAI](/providers/inference/azure-openai) | Azure OpenAI API key and resource name | Azure subscription | | [Baseten](/providers/inference/baseten) | Baseten API key | Baseten workspace | +| [Cloudflare AI Gateway](/providers/inference/cloudflare-ai-gateway) | Cloudflare API token, account ID, and gateway ID | Cloudflare account | +| [Cloudflare Workers AI](/providers/inference/cloudflare-workers-ai) | Cloudflare API token and account ID | Cloudflare account | | [Google Gemini](/providers/inference/google-gemini) | Google AI Studio key | Google Cloud project | | [MiniMax](/providers/inference/minimax) | MiniMax API key | MiniMax account | | [Moonshot AI (Kimi)](/providers/inference/moonshot-ai) | Kimi Open Platform key | Moonshot Open Platform balance | diff --git a/apps/docs/providers/inference/cloudflare-ai-gateway.mdx b/apps/docs/providers/inference/cloudflare-ai-gateway.mdx new file mode 100644 index 000000000..d043c8eb1 --- /dev/null +++ b/apps/docs/providers/inference/cloudflare-ai-gateway.mdx @@ -0,0 +1,70 @@ +--- +title: Cloudflare AI Gateway +icon: 'https://unpkg.com/@lobehub/icons-static-svg@1.94.0/icons/cloudflare.svg' +description: Route Roomote model calls through Cloudflare AI Gateway. +--- + +Cloudflare AI Gateway is a multi-vendor control plane for model calls. Use it +when you want one Cloudflare token, account, and gateway to reach models from +several providers, with Cloudflare's logging, caching, and routing in front. + +This is a separate connection from [Cloudflare Workers AI](/providers/inference/cloudflare-workers-ai). +Connecting AI Gateway does not connect Workers AI. + +## Get credentials + +Create a [Cloudflare API token](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/) +with **Account > Workers AI** permission. Roomote calls Cloudflare's unified +`/accounts//ai/v1` REST surface, and a token that only has AI Gateway +permission is rejected with 401. Copy the account ID from the Cloudflare +dashboard, then create or select an +[AI Gateway](https://developers.cloudflare.com/ai-gateway/get-started/) and +copy its gateway ID. A `default` gateway is created automatically on first use. + +Configure stored provider keys or unified billing in Cloudflare for the vendors +you plan to call through the gateway. + +## Configuration + +Add **Cloudflare AI Gateway** in **Settings > Models**, then enter the API +token, account ID, and gateway ID. Environment-variable configuration uses: + +```sh +CLOUDFLARE_AI_GATEWAY_API_TOKEN=... +CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID=your-account-id +CLOUDFLARE_AI_GATEWAY_ID=default +``` + +Roomote exposes supported models under the +`cloudflare-ai-gateway//` prefix and adds a recommended +cross-vendor set. Apply the recommended mapping or choose models individually +for each role. + +In gateway mode, the API token stays on the Roomote control plane. Requests are +proxied to Cloudflare's OpenAI-compatible `/ai/v1` surface with the account ID +in the URL and the gateway ID in the `cf-aig-gateway-id` header. + +## Cost behavior + +Cloudflare records gateway usage and applies the billing terms, stored-key +routing, and upstream provider pricing configured for the account. Roomote +records token usage and estimates model cost from metadata; Cloudflare usage +and invoice are authoritative. See [AI Gateway pricing](https://developers.cloudflare.com/ai-gateway/pricing/). + +## Verify setup + +1. save the API token, account ID, and gateway ID +2. confirm Cloudflare AI Gateway models appear in **Settings > Models** +3. enable a model and assign it to the coding role +4. run a small task and confirm it appears in Cloudflare AI Gateway logs + +## Common issues + +- **The token is rejected.** Confirm it is a Cloudflare API token with + Account > Workers AI permission. An AI Gateway-only token returns 401 on + the `/ai/v1` REST API Roomote uses. +- **A model cannot be routed.** Check that the vendor is enabled on the + selected gateway and that stored keys or unified billing cover that model. +- **Workers AI models fail.** `@cf/` models through AI Gateway still need + Workers AI access on the token. Connecting Workers AI as its own provider is + a separate step. diff --git a/apps/docs/providers/inference/cloudflare-workers-ai.mdx b/apps/docs/providers/inference/cloudflare-workers-ai.mdx new file mode 100644 index 000000000..76c91254f --- /dev/null +++ b/apps/docs/providers/inference/cloudflare-workers-ai.mdx @@ -0,0 +1,67 @@ +--- +title: Cloudflare Workers AI +icon: 'https://unpkg.com/@lobehub/icons-static-svg@1.94.0/icons/cloudflare.svg' +description: Use Cloudflare-hosted Workers AI models for Roomote tasks. +--- + +Cloudflare Workers AI hosts open and specialized models on Cloudflare's +network. Roomote's direct provider exposes a curated `@cf/` subset suited to +coding and agent work. + +This is a separate connection from [Cloudflare AI Gateway](/providers/inference/cloudflare-ai-gateway). +Connecting Workers AI does not require a gateway ID and does not connect AI +Gateway. + +## Get credentials + +Create a [Cloudflare API token](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/) +with Workers AI access. Copy the account ID from the Cloudflare dashboard. A +gateway ID is not used for this provider. + +Make sure the account has Workers AI enabled for the models you select. See +the [Workers AI model catalog](https://developers.cloudflare.com/workers-ai/models/). + +## Configuration + +Add **Cloudflare Workers AI** in **Settings > Models**, then enter the API +token and account ID. Environment-variable configuration uses: + +```sh +CLOUDFLARE_WORKERS_AI_API_TOKEN=... +CLOUDFLARE_WORKERS_AI_ACCOUNT_ID=your-account-id +``` + +Roomote adds supported models with the `cloudflare-workers-ai/` prefix, using +Cloudflare-hosted `@cf/` catalog IDs. Enable the models you want and assign the +default coding and specialized roles. Provider model names are case-sensitive, +so use the IDs shown in Roomote rather than typing a similar slug from another +gateway. + +In gateway mode, the API token stays on the Roomote control plane. Requests are +proxied to Cloudflare's OpenAI-compatible +`/accounts//ai/v1` surface. No gateway header is sent. + +## Cost behavior + +Cloudflare charges the connected account according to the selected Workers AI +model. Roomote records usage and estimates cost when model metadata includes +pricing; Cloudflare billing and the [Workers AI pricing page](https://developers.cloudflare.com/workers-ai/platform/pricing/) +are authoritative. + +## Verify setup + +1. save the API token and account ID +2. confirm Workers AI models appear in **Settings > Models** +3. enable one `@cf/` model and assign it as the coding model +4. run a small task and confirm the request appears in Workers AI usage + +## Common issues + +- **Authentication fails.** Confirm the token has Workers AI permission and + belongs to the configured account. +- **A model ID is rejected.** Use the exact `cloudflare-workers-ai/@cf/...` ID + shown in Roomote. IDs from AI Gateway or another provider are not + interchangeable. +- **Requests mention a missing gateway.** You are calling the AI Gateway + provider, not Workers AI. Connect **Cloudflare Workers AI** when you want + hosted `@cf/` models without a gateway ID. diff --git a/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx b/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx index 3e9d4d21a..d0794992c 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx @@ -45,6 +45,8 @@ describe('SetupDocs', () => { ['azure-cognitive-services', 'azure-foundry'], ['baseten', 'baseten'], ['chatgpt', 'chatgpt'], + ['cloudflare-ai-gateway', 'cloudflare-ai-gateway'], + ['cloudflare-workers-ai', 'cloudflare-workers-ai'], ['github-copilot', 'github-copilot'], ['google', 'google-gemini'], ['kimi-for-coding', 'kimi-for-coding'], diff --git a/apps/web/src/app/(onboarding)/setup/setup-docs.ts b/apps/web/src/app/(onboarding)/setup/setup-docs.ts index d9f8bc2cd..f6246e4e5 100644 --- a/apps/web/src/app/(onboarding)/setup/setup-docs.ts +++ b/apps/web/src/app/(onboarding)/setup/setup-docs.ts @@ -58,6 +58,8 @@ const MODEL_PROVIDER_DOC_PATHS: Partial< 'azure-cognitive-services': 'providers/inference/azure-foundry', baseten: 'providers/inference/baseten', chatgpt: 'providers/inference/chatgpt', + 'cloudflare-ai-gateway': 'providers/inference/cloudflare-ai-gateway', + 'cloudflare-workers-ai': 'providers/inference/cloudflare-workers-ai', 'github-copilot': 'providers/inference/github-copilot', google: 'providers/inference/google-gemini', 'kimi-for-coding': 'providers/inference/kimi-for-coding', diff --git a/apps/web/src/trpc/commands/task-models/index.test.ts b/apps/web/src/trpc/commands/task-models/index.test.ts index b21ca05c3..633ea582c 100644 --- a/apps/web/src/trpc/commands/task-models/index.test.ts +++ b/apps/web/src/trpc/commands/task-models/index.test.ts @@ -1,4 +1,8 @@ -import { normalizeTaskModelId } from '@roomote/types'; +import { + getSetupModelProviderAdditionalEnvFields, + normalizeTaskModelId, + SETUP_MODEL_PROVIDER_CATALOG, +} from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; @@ -93,6 +97,12 @@ import { const PROVIDER_ENV_VAR_NAMES = [ 'OPENROUTER_API_KEY', + 'AI_GATEWAY_API_KEY', + 'CLOUDFLARE_AI_GATEWAY_API_TOKEN', + 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID', + 'CLOUDFLARE_AI_GATEWAY_ID', + 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', 'OPENAI_API_KEY', 'AZURE_API_KEY', 'AZURE_RESOURCE_NAME', @@ -1550,17 +1560,27 @@ describe('task model provider commands', () => { const result = await getTaskModelProviderSetupCommand(buildMockAuth()); + const catalogNonSecretEnvNames = SETUP_MODEL_PROVIDER_CATALOG.flatMap( + (provider) => [ + ...(provider.authKind === 'endpoint' && provider.envVarName + ? [provider.envVarName] + : []), + ...getSetupModelProviderAdditionalEnvFields(provider) + .filter((field) => !field.secret) + .map((field) => field.envVarName), + ], + ); + expect(mockGetPersistedEnvironmentVariableValues).toHaveBeenCalledWith([ - 'AZURE_RESOURCE_NAME', - 'AZURE_COGNITIVE_SERVICES_RESOURCE_NAME', - 'AWS_REGION', - 'ZAI_REGION', - 'ZAI_CODING_PLAN_REGION', - 'OPENAI_COMPATIBLE_BASE_URL', - 'LITELLM_BASE_URL', - 'OLLAMA_BASE_URL', - 'VLLM_BASE_URL', + ...new Set([...catalogNonSecretEnvNames, 'OPENAI_COMPATIBLE_BASE_URL']), ]); + expect(catalogNonSecretEnvNames).toEqual( + expect.arrayContaining([ + 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID', + 'CLOUDFLARE_AI_GATEWAY_ID', + 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', + ]), + ); expect( result.providerSetup.providers.find( (provider) => provider.id === 'amazon-bedrock', diff --git a/apps/web/src/trpc/commands/task-models/models-dev.test.ts b/apps/web/src/trpc/commands/task-models/models-dev.test.ts index 7d81795b9..a57ed02e9 100644 --- a/apps/web/src/trpc/commands/task-models/models-dev.test.ts +++ b/apps/web/src/trpc/commands/task-models/models-dev.test.ts @@ -69,6 +69,25 @@ describe('resolveModelsDevSlug', () => { ); }); + it('strips the cloudflare-ai-gateway/ prefix for AI Gateway routed models', () => { + expect( + resolveModelsDevSlug('cloudflare-ai-gateway/openai/gpt-5.6-terra'), + ).toBe('openai/gpt-5.6-terra'); + expect( + resolveModelsDevSlug( + 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2', + ), + ).toBe('workers-ai/@cf/zai-org/glm-5.2'); + }); + + it('strips the cloudflare-workers-ai/ prefix for hosted Workers AI models', () => { + expect( + resolveModelsDevSlug( + 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + ), + ).toBe('@cf/moonshotai/kimi-k2.7-code'); + }); + it('maps Bedrock Mantle model ids to their models.dev lab slugs', () => { expect( resolveModelsDevSlug('bedrock-mantle/anthropic.claude-haiku-4-5'), diff --git a/apps/web/src/trpc/commands/task-models/models-dev.ts b/apps/web/src/trpc/commands/task-models/models-dev.ts index 3e7986f99..1a048de97 100644 --- a/apps/web/src/trpc/commands/task-models/models-dev.ts +++ b/apps/web/src/trpc/commands/task-models/models-dev.ts @@ -236,7 +236,8 @@ export async function fetchModelsDevCatalog( /** * Resolves the models.dev catalog slug for a Roomote task model id. * Strips a leading gateway provider prefix (`openrouter/`, `vercel/`, - * `requesty/`, `baseten/`, `togetherai/`) and any leading `~` alias marker. + * `requesty/`, `baseten/`, `togetherai/`, `cloudflare-ai-gateway/`, + * `cloudflare-workers-ai/`) and any leading `~` alias marker. * Mantle's `lab.model` identifiers are converted to models.dev's `lab/model` * slugs so metadata continues to resolve through the underlying model lab. */ diff --git a/apps/worker/src/run-task/agent-home.test.ts b/apps/worker/src/run-task/agent-home.test.ts index 6bea6d92f..5b8bdef6b 100644 --- a/apps/worker/src/run-task/agent-home.test.ts +++ b/apps/worker/src/run-task/agent-home.test.ts @@ -353,6 +353,184 @@ describe('generateOpenCodeConfig provider support', () => { }); }); + it('rebases Cloudflare AI Gateway onto the OpenAI-compatible gateway SDK', () => { + const result = generateOpenCodeConfig({ + homeDir: createHomeDir(), + runtimeEnv: { + R_MODEL: 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + R_INFERENCE_GATEWAY_URL: 'https://api.example.com/api/inference', + R_INFERENCE_GATEWAY_KEYS: 'CLOUDFLARE_AI_GATEWAY_API_TOKEN', + }, + }); + const config = JSON.parse(result.configContent) as { + provider: Record< + string, + { npm?: string; options?: Record } + >; + }; + + expect(config.provider['cloudflare-ai-gateway']).toMatchObject({ + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: + 'https://api.example.com/api/inference/cloudflare-ai-gateway/v1', + apiKey: '{env:ROOMOTE_CLOUD_TOKEN}', + }, + }); + }); + + it('rebases Cloudflare Workers AI onto the OpenAI-compatible gateway SDK', () => { + const result = generateOpenCodeConfig({ + homeDir: createHomeDir(), + runtimeEnv: { + R_MODEL: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + R_INFERENCE_GATEWAY_URL: 'https://api.example.com/api/inference', + R_INFERENCE_GATEWAY_KEYS: 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + }, + }); + const config = JSON.parse(result.configContent) as { + provider: Record< + string, + { npm?: string; options?: Record } + >; + }; + + expect(config.provider['cloudflare-workers-ai']).toMatchObject({ + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: + 'https://api.example.com/api/inference/cloudflare-workers-ai/v1', + apiKey: '{env:ROOMOTE_CLOUD_TOKEN}', + }, + }); + }); + + it('registers rewritten AI Gateway models when the gateway is serving the token', () => { + const result = generateOpenCodeConfig({ + homeDir: createHomeDir(), + runtimeEnv: { + R_MODEL: 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'default', + R_INFERENCE_GATEWAY_URL: 'https://api.example.com/api/inference', + R_INFERENCE_GATEWAY_KEYS: 'CLOUDFLARE_AI_GATEWAY_API_TOKEN', + }, + }); + const config = JSON.parse(result.configContent) as { + provider: Record< + string, + { models?: Record; options?: Record } + >; + }; + + expect(config.provider['cloudflare-ai-gateway']).toMatchObject({ + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: + 'https://api.example.com/api/inference/cloudflare-ai-gateway/v1', + apiKey: '{env:ROOMOTE_CLOUD_TOKEN}', + }, + models: { + '@cf/zai-org/glm-5.2': { name: '@cf/zai-org/glm-5.2' }, + }, + }); + expect(config.provider['cloudflare-ai-gateway']?.models).not.toHaveProperty( + 'workers-ai/@cf/zai-org/glm-5.2', + ); + }); + + it('binds Cloudflare AI Gateway to Roomote env names in direct mode', () => { + const result = generateOpenCodeConfig({ + homeDir: createHomeDir(), + runtimeEnv: { + R_MODEL: 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'cf-token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'my_gateway', + }, + }); + const config = JSON.parse(result.configContent) as { + provider: Record< + string, + { npm?: string; options?: Record } + >; + }; + + expect(config.provider['cloudflare-ai-gateway']).toMatchObject({ + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: + 'https://api.cloudflare.com/client/v4/accounts/a1b2c3d4e5f6789012345678abcdef90/ai/v1', + apiKey: '{env:CLOUDFLARE_AI_GATEWAY_API_TOKEN}', + headers: { 'cf-aig-gateway-id': 'my_gateway' }, + }, + }); + expect(result.configContent).not.toContain('cf-token'); + expect(config.provider['cloudflare-workers-ai']).toBeUndefined(); + }); + + it('binds Cloudflare Workers AI to Roomote env names in direct mode', () => { + const result = generateOpenCodeConfig({ + homeDir: createHomeDir(), + runtimeEnv: { + R_MODEL: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + CLOUDFLARE_WORKERS_AI_API_TOKEN: 'cf-token', + CLOUDFLARE_WORKERS_AI_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + }, + }); + const config = JSON.parse(result.configContent) as { + provider: Record< + string, + { npm?: string; options?: Record } + >; + }; + + expect(config.provider['cloudflare-workers-ai']).toMatchObject({ + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: + 'https://api.cloudflare.com/client/v4/accounts/a1b2c3d4e5f6789012345678abcdef90/ai/v1', + apiKey: '{env:CLOUDFLARE_WORKERS_AI_API_TOKEN}', + }, + }); + expect( + config.provider['cloudflare-workers-ai']?.options?.headers, + ).toBeUndefined(); + expect(result.configContent).not.toContain('cf-token'); + expect(config.provider['cloudflare-ai-gateway']).toBeUndefined(); + }); + + it('rewrites AI Gateway workers-ai/@cf models in direct mode', () => { + const result = generateOpenCodeConfig({ + homeDir: createHomeDir(), + runtimeEnv: { + R_MODEL: 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2', + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'cf-token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'default', + }, + }); + const overlay = JSON.parse(result.configContent) as { + provider: Record }>; + }; + const globalConfig = JSON.parse( + readFileSync(join(result.openCodeConfigDir, 'opencode.json'), 'utf8'), + ) as { + model?: string; + provider: Record }>; + }; + + expect(globalConfig.model).toBe( + 'cloudflare-ai-gateway/@cf/zai-org/glm-5.2', + ); + expect(overlay.provider['cloudflare-ai-gateway']?.models).toMatchObject({ + '@cf/zai-org/glm-5.2': { name: '@cf/zai-org/glm-5.2' }, + }); + expect( + overlay.provider['cloudflare-ai-gateway']?.models, + ).not.toHaveProperty('workers-ai/@cf/zai-org/glm-5.2'); + }); + it('rebases Azure providers onto the inference gateway without a /v1 suffix', () => { const result = generateOpenCodeConfig({ homeDir: createHomeDir(), diff --git a/apps/worker/src/run-task/agent-home.ts b/apps/worker/src/run-task/agent-home.ts index 3b6428d3b..6cc451f7f 100644 --- a/apps/worker/src/run-task/agent-home.ts +++ b/apps/worker/src/run-task/agent-home.ts @@ -32,7 +32,9 @@ import { mergeAmazonBedrockProviderConfig, mergeBedrockMantleOpenAiProviderConfig, mergeBedrockMantleProviderConfig, + mergeCloudflareOpenCodeProviderConfig, mergeOpenAiCompatibleProviderConfig, + rewriteCloudflareOpenCodeModelId, mergeOpenCodeModelReasoningOptions, mergeOpenCodeChatGptFastModeOptions, mergeOpenRouterVariantAliasModels, @@ -703,6 +705,12 @@ function asRecord(value: unknown): Record { : {}; } +function toOpenCodeRuntimeModelId(modelId: string): string { + return rewriteCloudflareOpenCodeModelId( + toBedrockMantleRuntimeModelId(modelId), + ); +} + /** * When the dequeue env carries an inference gateway URL, rebase each * gateway-covered provider that a selected model uses onto the gateway. The @@ -906,6 +914,9 @@ function rebaseProviderOntoGateway( ...providerConfig, [openCodeProviderId]: { ...existingProvider, + ...(gatewayProvider.openCodeNpm + ? { npm: gatewayProvider.openCodeNpm } + : {}), options: { ...existingOptions, baseURL: buildInferenceGatewayOpenCodeBaseUrl( @@ -1417,43 +1428,43 @@ function resolveModelBackedOpenCodeConfig( const normalizedModelOverride = modelOverride ? collectOpenRouterVariantModelAlias( variantAliases, - toBedrockMantleRuntimeModelId( + toOpenCodeRuntimeModelId( applyImplicitLiteLlmModelPrefix(modelOverride, isLiteLlmConfigured), ), ) : undefined; const model = collectOpenRouterVariantModelAlias( variantAliases, - toBedrockMantleRuntimeModelId(rawModel), + toOpenCodeRuntimeModelId(rawModel), ); const smallModel = rawSmallModel ? collectOpenRouterVariantModelAlias( variantAliases, - toBedrockMantleRuntimeModelId(rawSmallModel), + toOpenCodeRuntimeModelId(rawSmallModel), ) : undefined; const visionModel = rawVisionModel ? collectOpenRouterVariantModelAlias( variantAliases, - toBedrockMantleRuntimeModelId(rawVisionModel), + toOpenCodeRuntimeModelId(rawVisionModel), ) : undefined; const codeReviewModel = rawCodeReviewModel ? collectOpenRouterVariantModelAlias( variantAliases, - toBedrockMantleRuntimeModelId(rawCodeReviewModel), + toOpenCodeRuntimeModelId(rawCodeReviewModel), ) : undefined; const exploreModel = rawExploreModel ? collectOpenRouterVariantModelAlias( variantAliases, - toBedrockMantleRuntimeModelId(rawExploreModel), + toOpenCodeRuntimeModelId(rawExploreModel), ) : undefined; const planningModel = rawPlanningModel ? collectOpenRouterVariantModelAlias( variantAliases, - toBedrockMantleRuntimeModelId(rawPlanningModel), + toOpenCodeRuntimeModelId(rawPlanningModel), ) : undefined; const effectiveCodingModel = normalizedModelOverride ?? model; @@ -1633,20 +1644,24 @@ function resolveModelBackedOpenCodeConfig( ) : providerReasoningConfig; const providerConfig = mergeInferenceGatewayProviderConfig( - mergeOpenCodeGoProviderConfig( - mergeAzureCognitiveServicesProviderConfig( - mergeAmazonBedrockProviderConfig( - mergeBedrockMantleProviderConfig( - mergeBedrockMantleOpenAiProviderConfig( - mergeOpenAiCompatibleProviderConfig( - mergeOpenRouterVariantAliasModels( - providerModelConfig, - variantAliases, + mergeCloudflareOpenCodeProviderConfig( + mergeOpenCodeGoProviderConfig( + mergeAzureCognitiveServicesProviderConfig( + mergeAmazonBedrockProviderConfig( + mergeBedrockMantleProviderConfig( + mergeBedrockMantleOpenAiProviderConfig( + mergeOpenAiCompatibleProviderConfig( + mergeOpenRouterVariantAliasModels( + providerModelConfig, + variantAliases, + ), + runtimeEnv, + openAiCompatibleModelIds, + visionModel ?? effectiveCodingModel, + modelContextWindows, ), runtimeEnv, - openAiCompatibleModelIds, - visionModel ?? effectiveCodingModel, - modelContextWindows, + configuredModelIds, ), runtimeEnv, configuredModelIds, @@ -1654,11 +1669,11 @@ function resolveModelBackedOpenCodeConfig( runtimeEnv, configuredModelIds, ), - runtimeEnv, configuredModelIds, ), configuredModelIds, ), + runtimeEnv, configuredModelIds, ), runtimeEnv, @@ -1770,7 +1785,7 @@ export function generateOpenCodeConfig({ removeDisabledProviderConfiguration(runtimeEnv, homeDir); const configuredModel = resolveConfiguredPromptModel(model); const resolvedModel = configuredModel - ? toBedrockMantleRuntimeModelId(configuredModel) + ? toOpenCodeRuntimeModelId(configuredModel) : undefined; // A variant task model (`openrouter/...:nitro`) surfaces as its catalog base // model here (inline config + per-prompt model selection); the operator diff --git a/deploy/compose/docker-compose.prod.yml b/deploy/compose/docker-compose.prod.yml index ddcc2c212..316527e77 100644 --- a/deploy/compose/docker-compose.prod.yml +++ b/deploy/compose/docker-compose.prod.yml @@ -86,6 +86,11 @@ x-roomote-inference-env: &roomote-inference-env OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-} BASETEN_API_KEY: ${BASETEN_API_KEY:-} TOGETHER_API_KEY: ${TOGETHER_API_KEY:-} + CLOUDFLARE_AI_GATEWAY_API_TOKEN: ${CLOUDFLARE_AI_GATEWAY_API_TOKEN:-} + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: ${CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID:-} + CLOUDFLARE_AI_GATEWAY_ID: ${CLOUDFLARE_AI_GATEWAY_ID:-} + CLOUDFLARE_WORKERS_AI_API_TOKEN: ${CLOUDFLARE_WORKERS_AI_API_TOKEN:-} + CLOUDFLARE_WORKERS_AI_ACCOUNT_ID: ${CLOUDFLARE_WORKERS_AI_ACCOUNT_ID:-} x-roomote-web-env: &roomote-web-env <<: *roomote-inference-env diff --git a/docker-compose.production.yml b/docker-compose.production.yml index bdf31ef35..e130c1efd 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -55,6 +55,11 @@ x-roomote-production-env: &roomote-production-env OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-} BASETEN_API_KEY: ${BASETEN_API_KEY:-} TOGETHER_API_KEY: ${TOGETHER_API_KEY:-} + CLOUDFLARE_AI_GATEWAY_API_TOKEN: ${CLOUDFLARE_AI_GATEWAY_API_TOKEN:-} + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: ${CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID:-} + CLOUDFLARE_AI_GATEWAY_ID: ${CLOUDFLARE_AI_GATEWAY_ID:-} + CLOUDFLARE_WORKERS_AI_API_TOKEN: ${CLOUDFLARE_WORKERS_AI_API_TOKEN:-} + CLOUDFLARE_WORKERS_AI_ACCOUNT_ID: ${CLOUDFLARE_WORKERS_AI_ACCOUNT_ID:-} GITHUB_TOKEN: ${GITHUB_TOKEN:-} R_GITHUB_APP_SLUG: ${R_GITHUB_APP_SLUG:?R_GITHUB_APP_SLUG is required} R_GITHUB_APP_ID: ${R_GITHUB_APP_ID:-} diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml index c1d5e114f..68ecddef2 100644 --- a/docker-compose.self-host.yml +++ b/docker-compose.self-host.yml @@ -74,6 +74,11 @@ x-roomote-env: &roomote-env OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-} BASETEN_API_KEY: ${BASETEN_API_KEY:-} TOGETHER_API_KEY: ${TOGETHER_API_KEY:-} + CLOUDFLARE_AI_GATEWAY_API_TOKEN: ${CLOUDFLARE_AI_GATEWAY_API_TOKEN:-} + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: ${CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID:-} + CLOUDFLARE_AI_GATEWAY_ID: ${CLOUDFLARE_AI_GATEWAY_ID:-} + CLOUDFLARE_WORKERS_AI_API_TOKEN: ${CLOUDFLARE_WORKERS_AI_API_TOKEN:-} + CLOUDFLARE_WORKERS_AI_ACCOUNT_ID: ${CLOUDFLARE_WORKERS_AI_ACCOUNT_ID:-} GITHUB_TOKEN: ${GITHUB_TOKEN:-} R_GITHUB_APP_ID: ${R_GITHUB_APP_ID:-} R_GITHUB_APP_PRIVATE_KEY: ${R_GITHUB_APP_PRIVATE_KEY:-} diff --git a/ecosystem.config.js b/ecosystem.config.js index 190ae7c9b..5b07b5013 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -16,6 +16,11 @@ const DEFAULT_OPENCODE_PROVIDER_ENV_KEYS = [ 'OPENCODE_API_KEY', 'BASETEN_API_KEY', 'TOGETHER_API_KEY', + 'CLOUDFLARE_AI_GATEWAY_API_TOKEN', + 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID', + 'CLOUDFLARE_AI_GATEWAY_ID', + 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', 'GEMINI_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY', 'AWS_BEARER_TOKEN_BEDROCK', diff --git a/packages/cloud-agents/src/server/__tests__/non-task-provider-usage.test.ts b/packages/cloud-agents/src/server/__tests__/non-task-provider-usage.test.ts index f15909a5d..2db8a766d 100644 --- a/packages/cloud-agents/src/server/__tests__/non-task-provider-usage.test.ts +++ b/packages/cloud-agents/src/server/__tests__/non-task-provider-usage.test.ts @@ -543,6 +543,41 @@ describe('resolveOpenCodeSmallModel', () => { expect(sessionPromptMock.mock.calls[0]?.[0]).not.toHaveProperty('format'); }); + it('rewrites AI Gateway workers-ai/@cf slugs before the structured SDK prompt', async () => { + process.env = { + ...originalEnv, + OPENCODE_SDK_SERVER_URL: 'http://127.0.0.1:4096', + }; + mockResolveEffectiveModelRuntimeEnv.mockResolvedValue({ + R_MODEL: 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2', + }); + sessionPromptMock.mockResolvedValue({ + data: { + info: { error: null }, + parts: [{ type: 'text', text: 'ok' }], + }, + error: undefined, + }); + + const { generateTrackedNonTaskText, NON_TASK_INFERENCE_SURFACES } = + await import('../non-task-provider-usage.js'); + + await generateTrackedNonTaskText({ + surface: NON_TASK_INFERENCE_SURFACES.taskSummaryGeneration, + prompt: 'Summarize the change.', + }); + + expect(sessionPromptMock).toHaveBeenCalledWith( + expect.objectContaining({ + model: { + providerID: 'cloudflare-ai-gateway', + modelID: '@cf/zai-org/glm-5.2', + }, + }), + expect.anything(), + ); + }); + it('addresses Mantle GPT helper models by their runtime provider id', async () => { process.env = { ...originalEnv, diff --git a/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts b/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts index 8dc6d5e7b..a80a3c39e 100644 --- a/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts +++ b/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts @@ -64,6 +64,59 @@ describe('buildOpenCodeCliEnv', () => { }); }); + it('materializes Cloudflare AI Gateway with Roomote env names for helper inference', () => { + const env = buildOpenCodeCliEnv({ + R_MODEL: 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + R_SMALL_MODEL: 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2', + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'my_gateway', + }); + + expect(JSON.parse(env.OPENCODE_CONFIG_CONTENT ?? '{}')).toMatchObject({ + model: 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + small_model: 'cloudflare-ai-gateway/@cf/zai-org/glm-5.2', + provider: { + 'cloudflare-ai-gateway': { + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: + 'https://api.cloudflare.com/client/v4/accounts/a1b2c3d4e5f6789012345678abcdef90/ai/v1', + apiKey: '{env:CLOUDFLARE_AI_GATEWAY_API_TOKEN}', + headers: { 'cf-aig-gateway-id': 'my_gateway' }, + }, + }, + }, + }); + }); + + it('materializes Cloudflare Workers AI without a gateway header', () => { + const env = buildOpenCodeCliEnv({ + R_MODEL: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + CLOUDFLARE_WORKERS_AI_API_TOKEN: 'token', + CLOUDFLARE_WORKERS_AI_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + }); + + expect(JSON.parse(env.OPENCODE_CONFIG_CONTENT ?? '{}')).toMatchObject({ + model: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + provider: { + 'cloudflare-workers-ai': { + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: + 'https://api.cloudflare.com/client/v4/accounts/a1b2c3d4e5f6789012345678abcdef90/ai/v1', + apiKey: '{env:CLOUDFLARE_WORKERS_AI_API_TOKEN}', + }, + }, + }, + }); + expect( + JSON.parse(env.OPENCODE_CONFIG_CONTENT ?? '{}').provider[ + 'cloudflare-workers-ai' + ].options.headers, + ).toBeUndefined(); + }); + it('materializes LiteLLM provider config for restricted helper inference', () => { const env = buildOpenCodeCliEnv({ R_MODEL: 'litellm/qwen3.6:35b-unsloth', diff --git a/packages/cloud-agents/src/server/non-task-provider-usage.ts b/packages/cloud-agents/src/server/non-task-provider-usage.ts index 087f25cac..d9facecf8 100644 --- a/packages/cloud-agents/src/server/non-task-provider-usage.ts +++ b/packages/cloud-agents/src/server/non-task-provider-usage.ts @@ -7,7 +7,10 @@ import { type PermissionRuleset, } from '@opencode-ai/sdk/v2/client'; import { resolveEffectiveModelRuntimeEnv } from '@roomote/db/server'; -import { toBedrockMantleRuntimeModelId } from '@roomote/types'; +import { + rewriteCloudflareOpenCodeModelId, + toBedrockMantleRuntimeModelId, +} from '@roomote/types'; import type { z } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; @@ -185,6 +188,9 @@ function splitOpenCodeModelId(model: string): { providerID: string; modelID: string; } { + model = rewriteCloudflareOpenCodeModelId( + toBedrockMantleRuntimeModelId(model), + ); const separatorIndex = model.indexOf('/'); if (separatorIndex <= 0 || separatorIndex === model.length - 1) { @@ -319,7 +325,9 @@ async function resolveNonTaskModelRuntime(model?: string): Promise<{ // The prompt must address the same runtime provider id the helper // server's config registered (Bedrock Mantle GPT ids run under // `bedrock-mantle-openai`), mirroring the task worker's rewrite. - model: toBedrockMantleRuntimeModelId(resolvedModel), + model: rewriteCloudflareOpenCodeModelId( + toBedrockMantleRuntimeModelId(resolvedModel), + ), // An explicit model rides into the server lease env as the primary role // model so the config builder registers its provider — the deployment's // role models may not include it, and an unregistered Bedrock (or diff --git a/packages/cloud-agents/src/server/opencode-runtime.ts b/packages/cloud-agents/src/server/opencode-runtime.ts index d9a49b792..ccd11d97d 100644 --- a/packages/cloud-agents/src/server/opencode-runtime.ts +++ b/packages/cloud-agents/src/server/opencode-runtime.ts @@ -10,7 +10,9 @@ import { mergeAmazonBedrockProviderConfig, mergeBedrockMantleOpenAiProviderConfig, mergeBedrockMantleProviderConfig, + mergeCloudflareOpenCodeProviderConfig, mergeOpenAiCompatibleProviderConfig, + rewriteCloudflareOpenCodeModelId, mergeOpenCodeModelReasoningOptions, mergeOpenCodeChatGptFastModeOptions, mergeOpenRouterVariantAliasModels, @@ -54,24 +56,30 @@ function buildModelBackedOpenCodeConfigContent( // rewrite (and the provider registrations below) a Bedrock helper model // fails with ProviderModelNotFoundError before any request is made. const variantAliases = new Map(); - const model = collectOpenRouterVariantModelAlias( - variantAliases, - toBedrockMantleRuntimeModelId(rawModel), + const model = rewriteCloudflareOpenCodeModelId( + collectOpenRouterVariantModelAlias( + variantAliases, + toBedrockMantleRuntimeModelId(rawModel), + ), ); const rawSmallModel = env.R_SMALL_MODEL?.trim(); const smallModel = rawSmallModel && !isTaskModelIdDisabled(rawSmallModel) - ? collectOpenRouterVariantModelAlias( - variantAliases, - toBedrockMantleRuntimeModelId(rawSmallModel), + ? rewriteCloudflareOpenCodeModelId( + collectOpenRouterVariantModelAlias( + variantAliases, + toBedrockMantleRuntimeModelId(rawSmallModel), + ), ) : undefined; const rawVisionModel = env.R_VISION_MODEL?.trim(); const visionModel = rawVisionModel && !isTaskModelIdDisabled(rawVisionModel) - ? collectOpenRouterVariantModelAlias( - variantAliases, - toBedrockMantleRuntimeModelId(rawVisionModel), + ? rewriteCloudflareOpenCodeModelId( + collectOpenRouterVariantModelAlias( + variantAliases, + toBedrockMantleRuntimeModelId(rawVisionModel), + ), ) : undefined; const modelReasoningEffort = normalizeOptionalReasoningEffort( @@ -130,17 +138,21 @@ function buildModelBackedOpenCodeConfigContent( // Same Bedrock provider registrations the task worker applies: OpenCode's // catalog knows neither Mantle endpoint, and the native provider does not // read the deployment's bearer token on its own. - const providerConfig = mergeAmazonBedrockProviderConfig( - mergeBedrockMantleProviderConfig( - mergeBedrockMantleOpenAiProviderConfig( - mergeOpenAiCompatibleProviderConfig( - mergeOpenRouterVariantAliasModels( - providerModelConfig, - variantAliases, + const providerConfig = mergeCloudflareOpenCodeProviderConfig( + mergeAmazonBedrockProviderConfig( + mergeBedrockMantleProviderConfig( + mergeBedrockMantleOpenAiProviderConfig( + mergeOpenAiCompatibleProviderConfig( + mergeOpenRouterVariantAliasModels( + providerModelConfig, + variantAliases, + ), + env, + configuredModelIds, + visionModel, ), env, configuredModelIds, - visionModel, ), env, configuredModelIds, diff --git a/packages/types/src/__tests__/inference-gateway.test.ts b/packages/types/src/__tests__/inference-gateway.test.ts index 0798e555e..402e69b0b 100644 --- a/packages/types/src/__tests__/inference-gateway.test.ts +++ b/packages/types/src/__tests__/inference-gateway.test.ts @@ -4,10 +4,13 @@ import { CHATGPT_GATEWAY_PROVIDER_ID, getInferenceGatewayProvider, getInferenceGatewayProviderByEnvVarName, + INFERENCE_GATEWAY_IDENTITY_PATTERN, INFERENCE_GATEWAY_PROVIDER_ENV_VAR_NAMES, INFERENCE_GATEWAY_PROVIDERS, isInferenceGatewayCoveredEnvVar, parseInferenceGatewayKeys, + rewriteCloudflareAiGatewayRequestBody, + toCloudflareAiGatewayUpstreamModelId, } from '../inference-gateway'; import { getSetupModelProvider } from '../model-provider-config'; @@ -318,4 +321,103 @@ describe('inference gateway key lookups', () => { expect(parseInferenceGatewayKeys('')).toEqual([]); expect(parseInferenceGatewayKeys(undefined)).toEqual([]); }); + + it('registers Cloudflare AI Gateway with account URL templating and a required gateway header', () => { + const provider = getInferenceGatewayProvider('cloudflare-ai-gateway'); + + expect(provider).toMatchObject({ + envVarNames: ['CLOUDFLARE_AI_GATEWAY_API_TOKEN'], + upstreamBaseUrl: + 'https://api.cloudflare.com/client/v4/accounts/{resource}/ai', + resource: { envVarName: 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID' }, + authHeader: { name: 'authorization', scheme: 'bearer' }, + requiredHeaders: [ + { + envVarName: 'CLOUDFLARE_AI_GATEWAY_ID', + headerName: 'cf-aig-gateway-id', + }, + ], + openCodeNpm: '@ai-sdk/openai-compatible', + openCodeBaseUrlSuffix: '/v1', + }); + expect(provider?.allowedPaths).toEqual( + expect.arrayContaining([ + '/v1/chat/completions', + '/v1/embeddings', + '/v1/models', + ]), + ); + expect( + getInferenceGatewayProviderByEnvVarName('CLOUDFLARE_AI_GATEWAY_API_TOKEN') + ?.id, + ).toBe('cloudflare-ai-gateway'); + expect( + buildInferenceGatewayOpenCodeBaseUrl( + 'https://api.example.com/api/inference', + provider!, + ), + ).toBe('https://api.example.com/api/inference/cloudflare-ai-gateway/v1'); + }); + + it('registers Cloudflare Workers AI with account URL templating and no gateway id', () => { + const provider = getInferenceGatewayProvider('cloudflare-workers-ai'); + + expect(provider).toMatchObject({ + envVarNames: ['CLOUDFLARE_WORKERS_AI_API_TOKEN'], + upstreamBaseUrl: + 'https://api.cloudflare.com/client/v4/accounts/{resource}/ai', + resource: { envVarName: 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID' }, + authHeader: { name: 'authorization', scheme: 'bearer' }, + openCodeNpm: '@ai-sdk/openai-compatible', + openCodeBaseUrlSuffix: '/v1', + }); + expect(provider?.requiredHeaders).toBeUndefined(); + expect(provider?.allowedPaths).toEqual( + expect.arrayContaining([ + '/v1/chat/completions', + '/v1/embeddings', + '/v1/responses', + ]), + ); + expect( + getInferenceGatewayProviderByEnvVarName('CLOUDFLARE_WORKERS_AI_API_TOKEN') + ?.id, + ).toBe('cloudflare-workers-ai'); + expect( + buildInferenceGatewayOpenCodeBaseUrl( + 'https://api.example.com/api/inference', + provider!, + ), + ).toBe('https://api.example.com/api/inference/cloudflare-workers-ai/v1'); + }); + + it('accepts underscore gateway ids and rejects header-unsafe values', () => { + expect(INFERENCE_GATEWAY_IDENTITY_PATTERN.test('default')).toBe(true); + expect(INFERENCE_GATEWAY_IDENTITY_PATTERN.test('my_gateway')).toBe(true); + expect(INFERENCE_GATEWAY_IDENTITY_PATTERN.test('my-gateway')).toBe(true); + expect(INFERENCE_GATEWAY_IDENTITY_PATTERN.test('my gateway')).toBe(false); + expect(INFERENCE_GATEWAY_IDENTITY_PATTERN.test('gw\nid')).toBe(false); + }); + + it('strips the models.dev workers-ai namespace before /ai/v1', () => { + expect( + toCloudflareAiGatewayUpstreamModelId('workers-ai/@cf/zai-org/glm-5.2'), + ).toBe('@cf/zai-org/glm-5.2'); + expect(toCloudflareAiGatewayUpstreamModelId('openai/gpt-5.6-terra')).toBe( + 'openai/gpt-5.6-terra', + ); + expect( + rewriteCloudflareAiGatewayRequestBody( + JSON.stringify({ + model: 'workers-ai/@cf/zai-org/glm-5.2', + messages: [{ role: 'user', content: 'hi' }], + }), + ), + ).toBe( + JSON.stringify({ + model: '@cf/zai-org/glm-5.2', + messages: [{ role: 'user', content: 'hi' }], + }), + ); + }); }); diff --git a/packages/types/src/__tests__/opencode-provider-config.test.ts b/packages/types/src/__tests__/opencode-provider-config.test.ts index 2f17199f6..2756131cd 100644 --- a/packages/types/src/__tests__/opencode-provider-config.test.ts +++ b/packages/types/src/__tests__/opencode-provider-config.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { mergeOpenAiCompatibleProviderConfig } from '../opencode-provider-config'; +import { + mergeCloudflareOpenCodeProviderConfig, + mergeOpenAiCompatibleProviderConfig, +} from '../opencode-provider-config'; describe('mergeOpenAiCompatibleProviderConfig', () => { it('materializes LiteLLM provider metadata for selected models', () => { @@ -153,3 +156,131 @@ describe('mergeOpenAiCompatibleProviderConfig', () => { }); }); }); + +describe('mergeCloudflareOpenCodeProviderConfig', () => { + it('materializes AI Gateway with Roomote env names and a gateway header', () => { + expect( + mergeCloudflareOpenCodeProviderConfig( + {}, + { + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'my_gateway', + }, + ['cloudflare-ai-gateway/openai/gpt-5.6-terra'], + ), + ).toEqual({ + 'cloudflare-ai-gateway': { + npm: '@ai-sdk/openai-compatible', + name: 'Cloudflare AI Gateway', + options: { + baseURL: + 'https://api.cloudflare.com/client/v4/accounts/a1b2c3d4e5f6789012345678abcdef90/ai/v1', + apiKey: '{env:CLOUDFLARE_AI_GATEWAY_API_TOKEN}', + headers: { 'cf-aig-gateway-id': 'my_gateway' }, + }, + models: { + 'openai/gpt-5.6-terra': { name: 'openai/gpt-5.6-terra' }, + }, + }, + }); + }); + + it('rewrites workers-ai/@cf models to @cf for the /ai/v1 surface', () => { + const merged = mergeCloudflareOpenCodeProviderConfig( + {}, + { + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'default', + }, + ['cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2'], + ); + + expect( + merged['cloudflare-ai-gateway'] as { models?: Record }, + ).toMatchObject({ + models: { + '@cf/zai-org/glm-5.2': { name: '@cf/zai-org/glm-5.2' }, + }, + }); + expect( + (merged['cloudflare-ai-gateway'] as { models?: Record }) + .models, + ).not.toHaveProperty('workers-ai/@cf/zai-org/glm-5.2'); + }); + + it('does not treat a complete AI Gateway config as Workers AI config', () => { + const workersAi = mergeCloudflareOpenCodeProviderConfig( + {}, + { + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'default', + }, + [ + 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + ], + )['cloudflare-workers-ai'] as + | { options?: Record } + | undefined; + + expect(workersAi?.options?.apiKey).toBeUndefined(); + expect(workersAi?.options?.baseURL).toBeUndefined(); + }); + + it('registers rewritten AI Gateway models when the token is withheld', () => { + const merged = mergeCloudflareOpenCodeProviderConfig( + {}, + { + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'default', + }, + ['cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2'], + ); + + expect(merged['cloudflare-ai-gateway']).toMatchObject({ + npm: '@ai-sdk/openai-compatible', + models: { + '@cf/zai-org/glm-5.2': { name: '@cf/zai-org/glm-5.2' }, + }, + }); + expect( + (merged['cloudflare-ai-gateway'] as { options?: Record }) + .options?.baseURL, + ).toBeUndefined(); + expect( + (merged['cloudflare-ai-gateway'] as { options?: Record }) + .options?.apiKey, + ).toBeUndefined(); + }); + + it('materializes Workers AI without a gateway header', () => { + expect( + mergeCloudflareOpenCodeProviderConfig( + {}, + { + CLOUDFLARE_WORKERS_AI_API_TOKEN: 'token', + CLOUDFLARE_WORKERS_AI_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + }, + ['cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code'], + ), + ).toEqual({ + 'cloudflare-workers-ai': { + npm: '@ai-sdk/openai-compatible', + name: 'Cloudflare Workers AI', + options: { + baseURL: + 'https://api.cloudflare.com/client/v4/accounts/a1b2c3d4e5f6789012345678abcdef90/ai/v1', + apiKey: '{env:CLOUDFLARE_WORKERS_AI_API_TOKEN}', + }, + models: { + '@cf/moonshotai/kimi-k2.7-code': { + name: '@cf/moonshotai/kimi-k2.7-code', + }, + }, + }, + }); + }); +}); diff --git a/packages/types/src/inference-gateway.ts b/packages/types/src/inference-gateway.ts index c20812be7..480aae55f 100644 --- a/packages/types/src/inference-gateway.ts +++ b/packages/types/src/inference-gateway.ts @@ -46,6 +46,84 @@ export const INFERENCE_GATEWAY_REGION_PATTERN = export const INFERENCE_GATEWAY_RESOURCE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu; +/** + * Non-DNS identity values injected as headers (Cloudflare gateway ids). + * Allows underscores and up to 64 characters; rejects spaces and CR/LF. + */ +export const INFERENCE_GATEWAY_IDENTITY_PATTERN = + /^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62}[A-Za-z0-9])?$/u; + +const CLOUDFLARE_WORKERS_AI_CATALOG_PREFIX = 'workers-ai/'; + +/** + * models.dev stores hosted Workers AI models under a `workers-ai/` namespace + * on the AI Gateway provider. Cloudflare's `/ai/v1` surface expects the + * `@cf/...` id with that namespace removed. + */ +export function toCloudflareAiGatewayUpstreamModelId(modelId: string): string { + const trimmed = modelId.trim(); + + if ( + trimmed.startsWith(CLOUDFLARE_WORKERS_AI_CATALOG_PREFIX) && + trimmed + .slice(CLOUDFLARE_WORKERS_AI_CATALOG_PREFIX.length) + .startsWith('@cf/') + ) { + return trimmed.slice(CLOUDFLARE_WORKERS_AI_CATALOG_PREFIX.length); + } + + return trimmed; +} + +/** Rewrites a Roomote task model id for OpenCode / Cloudflare `/ai/v1`. */ +export function rewriteCloudflareOpenCodeModelId(modelId: string): string { + const prefix = 'cloudflare-ai-gateway/'; + + if (!modelId.startsWith(prefix)) { + return modelId; + } + + return `${prefix}${toCloudflareAiGatewayUpstreamModelId(modelId.slice(prefix.length))}`; +} + +/** + * Rewrites a JSON chat-completions body so Cloudflare `/ai/v1` receives + * `@cf/...` instead of models.dev's `workers-ai/@cf/...` catalog slug. + */ +export function rewriteCloudflareAiGatewayRequestBody( + bodyText: string, +): string { + if (!bodyText.trim()) { + return bodyText; + } + + let body: unknown; + + try { + body = JSON.parse(bodyText); + } catch { + return bodyText; + } + + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return bodyText; + } + + const record = body as { model?: unknown }; + + if (typeof record.model !== 'string') { + return bodyText; + } + + const rewritten = toCloudflareAiGatewayUpstreamModelId(record.model); + + if (rewritten === record.model) { + return bodyText; + } + + return JSON.stringify({ ...record, model: rewritten }); +} + /** Default AWS region for the Bedrock Mantle Anthropic-compatible endpoint. */ export const DEFAULT_BEDROCK_MANTLE_REGION = 'us-east-1'; @@ -160,6 +238,15 @@ export interface InferenceGatewayProvider { resource?: { envVarName: string; }; + /** + * Extra headers resolved from deployment env vars and injected on every + * forwarded request. Used when a second identity value cannot fit in + * `{resource}` (Cloudflare AI Gateway's `cf-aig-gateway-id`). + */ + requiredHeaders?: readonly { + envVarName: string; + headerName: string; + }[]; /** How the upstream expects its API key when the gateway forwards. */ authHeader?: InferenceGatewayAuthHeader; /** A configured upstream key is forwarded when present but is not required. */ @@ -183,6 +270,13 @@ export interface InferenceGatewayProvider { * like `/messages` or `/chat/completions` below it). */ openCodeBaseUrlSuffix: string; + /** + * OpenCode npm package to install when this provider is rebased onto the + * Roomote inference gateway. Required when models.dev's package is not an + * OpenAI-compatible factory (Cloudflare AI Gateway's `ai-gateway-provider` + * would otherwise ignore Roomote's `/v1/chat/completions` route). + */ + openCodeNpm?: string; } /** @@ -340,6 +434,42 @@ export const INFERENCE_GATEWAY_PROVIDERS: readonly InferenceGatewayProvider[] = allowedPaths: OPENAI_COMPATIBLE_INFERENCE_PATHS, openCodeBaseUrlSuffix: '/v1', }, + { + id: 'cloudflare-ai-gateway', + name: 'Cloudflare AI Gateway', + envVarNames: ['CLOUDFLARE_AI_GATEWAY_API_TOKEN'], + upstreamBaseUrl: + 'https://api.cloudflare.com/client/v4/accounts/{resource}/ai', + resource: { envVarName: 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID' }, + requiredHeaders: [ + { + envVarName: 'CLOUDFLARE_AI_GATEWAY_ID', + headerName: 'cf-aig-gateway-id', + }, + ], + authHeader: { name: 'authorization', scheme: 'bearer' }, + allowedPaths: [ + ...OPENAI_COMPATIBLE_INFERENCE_PATHS, + ...OPENAI_RESPONSES_INFERENCE_PATHS, + ], + openCodeNpm: '@ai-sdk/openai-compatible', + openCodeBaseUrlSuffix: '/v1', + }, + { + id: 'cloudflare-workers-ai', + name: 'Cloudflare Workers AI', + envVarNames: ['CLOUDFLARE_WORKERS_AI_API_TOKEN'], + upstreamBaseUrl: + 'https://api.cloudflare.com/client/v4/accounts/{resource}/ai', + resource: { envVarName: 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID' }, + authHeader: { name: 'authorization', scheme: 'bearer' }, + allowedPaths: [ + ...OPENAI_COMPATIBLE_INFERENCE_PATHS, + ...OPENAI_RESPONSES_INFERENCE_PATHS, + ], + openCodeNpm: '@ai-sdk/openai-compatible', + openCodeBaseUrlSuffix: '/v1', + }, { id: 'moonshotai', name: 'Moonshot AI', diff --git a/packages/types/src/model-provider-config.test.ts b/packages/types/src/model-provider-config.test.ts index 00fce55e5..24aa80ab4 100644 --- a/packages/types/src/model-provider-config.test.ts +++ b/packages/types/src/model-provider-config.test.ts @@ -244,6 +244,8 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { 'requesty', 'baseten', 'togetherai', + 'cloudflare-ai-gateway', + 'cloudflare-workers-ai', 'openai', 'azure', 'azure-cognitive-services', @@ -351,6 +353,10 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { { providerId: 'requesty', modelId: 'requesty/kimi-k3' }, { providerId: 'baseten', modelId: 'baseten/moonshotai/Kimi-K3' }, { providerId: 'togetherai', modelId: 'togetherai/moonshotai/Kimi-K3' }, + { + providerId: 'cloudflare-ai-gateway', + modelId: 'cloudflare-ai-gateway/moonshotai/kimi-k3', + }, { providerId: 'moonshotai', modelId: 'moonshotai/kimi-k3' }, { providerId: 'kimi-for-coding', modelId: 'kimi-for-coding/k3' }, { providerId: 'opencode', modelId: 'opencode/kimi-k3' }, @@ -464,6 +470,10 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { { providerId: 'openrouter', modelId: `openrouter/openai/${modelId}` }, { providerId: 'vercel', modelId: `vercel/openai/${modelId}` }, { providerId: 'requesty', modelId: `requesty/${modelId}@eu` }, + { + providerId: 'cloudflare-ai-gateway', + modelId: `cloudflare-ai-gateway/openai/${modelId}`, + }, { providerId: 'openai', modelId: `openai/${modelId}` }, { providerId: 'azure', modelId: `azure/${modelId}` }, { @@ -1892,3 +1902,211 @@ describe('collectSetupModelProviderCredentialValues', () => { ).toThrow('Enter a valid Region for Z.AI to save it.'); }); }); + +describe('Cloudflare inference providers', () => { + const gatewayProvider = SETUP_MODEL_PROVIDER_CATALOG.find( + (provider) => provider.id === 'cloudflare-ai-gateway', + ); + const workersProvider = SETUP_MODEL_PROVIDER_CATALOG.find( + (provider) => provider.id === 'cloudflare-workers-ai', + ); + + it('exposes Cloudflare AI Gateway and Workers AI as two catalog providers', () => { + expect(gatewayProvider).toMatchObject({ + id: 'cloudflare-ai-gateway', + label: 'Cloudflare AI Gateway', + envVarName: 'CLOUDFLARE_AI_GATEWAY_API_TOKEN', + envVarLabel: 'API token', + authKind: 'api-key', + defaultRoomoteModel: 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + }); + expect(gatewayProvider?.credentialHelp?.text).toMatch( + /Workers AI (access|permission)/u, + ); + expect(gatewayProvider?.credentialHelp?.text).not.toMatch( + /token with AI Gateway access/u, + ); + expect(gatewayProvider?.additionalEnvFields).toEqual([ + { + envVarName: 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID', + label: 'Account ID', + secret: false, + required: true, + placeholder: 'your-account-id', + }, + { + envVarName: 'CLOUDFLARE_AI_GATEWAY_ID', + label: 'Gateway ID', + secret: false, + required: true, + placeholder: 'default', + }, + ]); + expect( + gatewayProvider?.suggestedTaskModels.map((model) => model.id), + ).toEqual( + expect.arrayContaining([ + 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + 'cloudflare-ai-gateway/anthropic/claude-sonnet-5', + 'cloudflare-ai-gateway/moonshotai/kimi-k3', + ]), + ); + expect( + gatewayProvider?.suggestedTaskModels.every((model) => + model.id.startsWith('cloudflare-ai-gateway/'), + ), + ).toBe(true); + + expect(workersProvider).toMatchObject({ + id: 'cloudflare-workers-ai', + label: 'Cloudflare Workers AI', + envVarName: 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + envVarLabel: 'API token', + authKind: 'api-key', + defaultRoomoteModel: + 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + }); + expect(workersProvider?.additionalEnvFields).toEqual([ + { + envVarName: 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', + label: 'Account ID', + secret: false, + required: true, + placeholder: 'your-account-id', + }, + ]); + expect( + workersProvider?.suggestedTaskModels.map((model) => model.id), + ).toEqual( + expect.arrayContaining([ + 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + 'cloudflare-workers-ai/@cf/zai-org/glm-5.2', + ]), + ); + expect( + workersProvider?.suggestedTaskModels.every((model) => + model.id.startsWith('cloudflare-workers-ai/'), + ), + ).toBe(true); + + expect(getModelProviderLabel('cloudflare-ai-gateway')).toBe( + 'Cloudflare AI Gateway', + ); + expect(getModelProviderLabel('cloudflare-workers-ai')).toBe( + 'Cloudflare Workers AI', + ); + expect( + resolveSetupModelProviderIdFromModel( + 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + ), + ).toBe('cloudflare-ai-gateway'); + expect( + resolveSetupModelProviderIdFromModel( + 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + ), + ).toBe('cloudflare-workers-ai'); + expect( + getModelProviderEnvKeyCandidates({ + providerId: 'cloudflare-ai-gateway', + }), + ).toEqual([ + 'CLOUDFLARE_AI_GATEWAY_API_TOKEN', + 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID', + 'CLOUDFLARE_AI_GATEWAY_ID', + ]); + expect( + getModelProviderEnvKeyCandidates({ + providerId: 'cloudflare-workers-ai', + }), + ).toEqual([ + 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', + ]); + expect(DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES).toContain( + 'CLOUDFLARE_AI_GATEWAY_API_TOKEN', + ); + expect(DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES).toContain( + 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + ); + expect(DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES).not.toContain( + 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID', + ); + expect(DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES).not.toContain( + 'CLOUDFLARE_AI_GATEWAY_ID', + ); + expect(DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES).not.toContain( + 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', + ); + }); + + it('does not treat a complete AI Gateway config as Workers AI connectedness', () => { + const status = buildSetupModelStatus({ + runtimeEnv: { + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'cf-gateway-token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'default', + }, + }); + + expect( + status.providers.find( + (provider) => provider.id === 'cloudflare-ai-gateway', + ), + ).toMatchObject({ + runtimeApiKeySatisfied: true, + savedApiKeySatisfied: false, + }); + expect( + status.providers.find( + (provider) => provider.id === 'cloudflare-workers-ai', + ), + ).toMatchObject({ + runtimeApiKeySatisfied: false, + savedApiKeySatisfied: false, + }); + }); + + it('does not treat a complete Workers AI config as AI Gateway connectedness', () => { + const status = buildSetupModelStatus({ + persistedEnvVarNames: [ + 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', + ], + persistedEnvVarValues: { + CLOUDFLARE_WORKERS_AI_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + }, + }); + + expect( + status.providers.find( + (provider) => provider.id === 'cloudflare-workers-ai', + ), + ).toMatchObject({ + runtimeApiKeySatisfied: false, + savedApiKeySatisfied: true, + }); + expect( + status.providers.find( + (provider) => provider.id === 'cloudflare-ai-gateway', + ), + ).toMatchObject({ + runtimeApiKeySatisfied: false, + savedApiKeySatisfied: false, + }); + }); + + it('requires the AI Gateway id in addition to the token and account', () => { + const status = buildSetupModelStatus({ + runtimeEnv: { + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'cf-gateway-token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + }, + }); + + expect( + status.providers.find( + (provider) => provider.id === 'cloudflare-ai-gateway', + )?.runtimeApiKeySatisfied, + ).toBe(false); + }); +}); diff --git a/packages/types/src/model-provider-config.ts b/packages/types/src/model-provider-config.ts index 885e18313..4a93bd330 100644 --- a/packages/types/src/model-provider-config.ts +++ b/packages/types/src/model-provider-config.ts @@ -444,6 +444,85 @@ export const SETUP_MODEL_PROVIDER_CATALOG = [ 'minimax-m3': 'togetherai/MiniMaxAI/MiniMax-M3', }), }, + { + // Provider id matches the models.dev `cloudflare-ai-gateway` provider so + // catalog suggestion derivation and gateway pricing lookup resolve + // against that multi-vendor catalog. + id: 'cloudflare-ai-gateway', + label: 'Cloudflare AI Gateway', + envVarName: 'CLOUDFLARE_AI_GATEWAY_API_TOKEN', + envVarLabel: 'API token', + authKind: 'api-key', + credentialHelp: { + text: 'Create a Cloudflare API token with Account > Workers AI permission. The /ai/v1 REST API Roomote uses rejects tokens that only have AI Gateway permission. Use the account ID and gateway ID from the Cloudflare dashboard. Connecting this provider does not connect the Workers AI provider.', + href: 'https://developers.cloudflare.com/ai-gateway/get-started/', + linkLabel: 'Open Cloudflare AI Gateway docs', + }, + additionalEnvFields: [ + { + envVarName: 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID', + label: 'Account ID', + secret: false, + required: true, + placeholder: 'your-account-id', + }, + { + envVarName: 'CLOUDFLARE_AI_GATEWAY_ID', + label: 'Gateway ID', + secret: false, + required: true, + placeholder: 'default', + }, + ], + defaultRoomoteModel: 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + suggestedTaskModels: mapRecommendedTaskModels({ + 'claude-fable-5': 'cloudflare-ai-gateway/anthropic/claude-fable-5', + 'claude-haiku-4-5': 'cloudflare-ai-gateway/anthropic/claude-haiku-4-5', + 'claude-opus-5': 'cloudflare-ai-gateway/anthropic/claude-opus-5', + 'claude-sonnet-5': 'cloudflare-ai-gateway/anthropic/claude-sonnet-5', + 'gpt-5-6-sol': 'cloudflare-ai-gateway/openai/gpt-5.6-sol', + 'gpt-5-6-terra': 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + 'gpt-5-6-luna': 'cloudflare-ai-gateway/openai/gpt-5.6-luna', + 'glm-5-2': 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2', + 'kimi-k3': 'cloudflare-ai-gateway/moonshotai/kimi-k3', + }), + recommendedRoleModels: { + helper: 'cloudflare-ai-gateway/openai/gpt-5.6-luna', + codeReview: 'cloudflare-ai-gateway/anthropic/claude-sonnet-5', + explore: 'cloudflare-ai-gateway/openai/gpt-5.6-luna', + planning: 'cloudflare-ai-gateway/anthropic/claude-opus-5', + }, + recommendedRoleReasoningEfforts: { codeReview: 'medium' }, + }, + { + // Provider id matches the models.dev `cloudflare-workers-ai` provider so + // catalog suggestion derivation and gateway pricing lookup resolve + // against Cloudflare-hosted `@cf/` models. + id: 'cloudflare-workers-ai', + label: 'Cloudflare Workers AI', + envVarName: 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + envVarLabel: 'API token', + authKind: 'api-key', + credentialHelp: { + text: 'Create a Cloudflare API token with Workers AI access. Use the account ID from the Cloudflare dashboard. A gateway ID is not required, and connecting this provider does not connect AI Gateway.', + href: 'https://developers.cloudflare.com/workers-ai/configuration/open-ai-compatibility/', + linkLabel: 'Open Cloudflare Workers AI docs', + }, + additionalEnvFields: [ + { + envVarName: 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', + label: 'Account ID', + secret: false, + required: true, + placeholder: 'your-account-id', + }, + ], + defaultRoomoteModel: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + suggestedTaskModels: mapRecommendedTaskModels({ + 'glm-5-2': 'cloudflare-workers-ai/@cf/zai-org/glm-5.2', + 'kimi-k2-7-code': 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + }), + }, { id: 'openai', label: 'OpenAI', diff --git a/packages/types/src/opencode-provider-config.ts b/packages/types/src/opencode-provider-config.ts index 741412ebb..5549365b2 100644 --- a/packages/types/src/opencode-provider-config.ts +++ b/packages/types/src/opencode-provider-config.ts @@ -1,3 +1,11 @@ +import { + getInferenceGatewayProvider, + INFERENCE_GATEWAY_IDENTITY_PATTERN, + INFERENCE_GATEWAY_RESOURCE_PATTERN, + toCloudflareAiGatewayUpstreamModelId, + type InferenceGatewayProvider, +} from './inference-gateway'; +import { getSetupModelProvider } from './model-provider-config'; import { buildOpenAiCompatibleProviderInstance, getOpenAiCompatibleProviderInstance, @@ -299,3 +307,142 @@ export function mergeOpenAiCompatibleProviderConfig( return merged; } + +const CLOUDFLARE_OPENCODE_PROVIDER_IDS = [ + 'cloudflare-ai-gateway', + 'cloudflare-workers-ai', +] as const; + +function readRequiredEnv( + runtimeEnv: RuntimeEnv, + envVarName: string | undefined, +): string | undefined { + return envVarName ? runtimeEnv[envVarName]?.trim() || undefined : undefined; +} + +/** + * Emit openai-compat providers against Cloudflare `/ai/v1` using Roomote's + * namespaced env vars, not models.dev's shared `CLOUDFLARE_ACCOUNT_ID`. Used + * by control-plane helpers and by direct-mode task execution when the + * inference gateway is absent. + */ +export function mergeCloudflareOpenCodeProviderConfig( + providerConfig: Record, + runtimeEnv: RuntimeEnv, + modelIds: Array, +): Record { + let merged = providerConfig; + + for (const providerId of CLOUDFLARE_OPENCODE_PROVIDER_IDS) { + const gatewayProvider = getInferenceGatewayProvider(providerId); + const setupProvider = getSetupModelProvider(providerId); + + if (!gatewayProvider?.resource || !setupProvider.envVarName) { + continue; + } + + const prefix = `${providerId}/`; + const modelIdsForProvider = [ + ...new Set( + modelIds.flatMap((modelId) => { + const normalized = modelId?.trim(); + return normalized?.startsWith(prefix) + ? [ + providerId === 'cloudflare-ai-gateway' + ? toCloudflareAiGatewayUpstreamModelId( + normalized.slice(prefix.length), + ) + : normalized.slice(prefix.length), + ] + : []; + }), + ), + ]; + + if (modelIdsForProvider.length === 0) { + continue; + } + + const existingProvider = asRecord(merged[providerId]); + const existingOptions = asRecord(existingProvider.options); + const existingModels = asRecord(existingProvider.models); + const options: Record = { + ...existingOptions, + }; + const apiKey = readRequiredEnv(runtimeEnv, setupProvider.envVarName); + const accountId = readRequiredEnv( + runtimeEnv, + gatewayProvider.resource.envVarName, + ); + // Register rewritten models even when the token is withheld so gateway + // mode can select `@cf/...` ids. Attach a direct `/ai/v1` URL only when + // the namespaced credentials are present in this env. + if ( + apiKey && + accountId && + INFERENCE_GATEWAY_RESOURCE_PATTERN.test(accountId) + ) { + options.baseURL = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`; + options.apiKey = `{env:${setupProvider.envVarName}}`; + + if ( + !appendRequiredCloudflareHeaders(options, gatewayProvider, runtimeEnv) + ) { + delete options.baseURL; + delete options.apiKey; + delete options.headers; + } + } + + merged = { + ...merged, + [providerId]: { + ...existingProvider, + npm: gatewayProvider.openCodeNpm ?? '@ai-sdk/openai-compatible', + name: setupProvider.label, + options, + models: { + ...existingModels, + ...Object.fromEntries( + modelIdsForProvider.map((modelId) => [ + modelId, + { + name: modelId, + ...asRecord(existingModels[modelId]), + }, + ]), + ), + }, + }, + }; + } + + return merged; +} + +function appendRequiredCloudflareHeaders( + options: Record, + gatewayProvider: InferenceGatewayProvider, + runtimeEnv: RuntimeEnv, +): boolean { + if (!gatewayProvider.requiredHeaders?.length) { + return true; + } + + const headers = { + ...asRecord(options.headers), + }; + + for (const spec of gatewayProvider.requiredHeaders) { + const value = readRequiredEnv(runtimeEnv, spec.envVarName); + + if (!value || !INFERENCE_GATEWAY_IDENTITY_PATTERN.test(value)) { + return false; + } + + headers[spec.headerName] = value; + } + + options.headers = headers; + return true; +} diff --git a/packages/types/src/task-models.test.ts b/packages/types/src/task-models.test.ts index 4d50f92cd..2f60686c5 100644 --- a/packages/types/src/task-models.test.ts +++ b/packages/types/src/task-models.test.ts @@ -70,6 +70,20 @@ describe('normalizeTaskModelId', () => { expect(normalizeTaskModelId('togetherai/deepseek-ai/DeepSeek-V4-Pro')).toBe( 'togetherai/deepseek-ai/DeepSeek-V4-Pro', ); + expect( + normalizeTaskModelId('cloudflare-ai-gateway/openai/gpt-5.6-terra'), + ).toBe('cloudflare-ai-gateway/openai/gpt-5.6-terra'); + expect(normalizeTaskModelId('cloudflare-ai-gateway/custom-route')).toBe( + 'cloudflare-ai-gateway/custom-route', + ); + expect( + normalizeTaskModelId( + 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + ), + ).toBe('cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code'); + expect(normalizeTaskModelId('cloudflare-workers-ai/custom-route')).toBe( + 'cloudflare-workers-ai/custom-route', + ); expect(normalizeTaskModelId('opencode/big-pickle')).toBe( 'opencode/big-pickle', ); diff --git a/packages/types/src/task-models.ts b/packages/types/src/task-models.ts index 3d7107feb..ff1f59d2a 100644 --- a/packages/types/src/task-models.ts +++ b/packages/types/src/task-models.ts @@ -21,6 +21,8 @@ export const ENABLED_DIRECT_TASK_MODEL_PROVIDER_IDS = [ 'requesty', 'baseten', 'togetherai', + 'cloudflare-ai-gateway', + 'cloudflare-workers-ai', 'openai', 'azure', 'azure-cognitive-services', @@ -81,6 +83,8 @@ export const GATEWAY_TASK_MODEL_PROVIDER_IDS = [ 'requesty', 'baseten', 'togetherai', + 'cloudflare-ai-gateway', + 'cloudflare-workers-ai', ] as const; export const TASK_MODEL_INPUT_TYPES = [