From ae15fc641e4cfda84dc800b4d4b76c8cd82355f2 Mon Sep 17 00:00:00 2001 From: Pride Musvaire Date: Sat, 26 Sep 2026 03:41:31 +0200 Subject: [PATCH 1/2] Add Cloudflare AI Gateway and Workers AI as built-in model providers on the catalog architecture. Operators connect one Cloudflare token, account, and gateway id (AI Gateway) or token and account (Workers AI); OpenCode registers both providers in full, the inference gateway injects the cf-aig-gateway-id header and rewrites Workers AI catalog slugs onto Cloudflare's /ai/v1 surface, and non-task helpers reuse the same registration so configured reasoning applies. --- README.md | 4 +- SELF_HOSTING.md | 16 +- .../__tests__/inference-gateway.test.ts | 210 ++++++++++++++ apps/api/src/handlers/inference/index.ts | 9 + apps/api/src/handlers/inference/registry.ts | 41 ++- apps/docs/docs.json | 2 + apps/docs/environment-variables.mdx | 7 +- apps/docs/models.mdx | 2 + .../inference/cloudflare-ai-gateway.mdx | 70 +++++ .../inference/cloudflare-workers-ai.mdx | 67 +++++ .../setup/SetupDocs.client.test.tsx | 2 + .../src/app/(onboarding)/setup/setup-docs.ts | 2 + .../trpc/commands/task-models/index.test.ts | 35 ++- .../commands/task-models/models-dev.test.ts | 19 ++ .../trpc/commands/task-models/models-dev.ts | 3 +- apps/worker/src/run-task/agent-home.test.ts | 178 ++++++++++++ apps/worker/src/run-task/agent-home.ts | 63 +++-- deploy/compose/docker-compose.prod.yml | 5 + docker-compose.production.yml | 5 + docker-compose.self-host.yml | 5 + ecosystem.config.js | 5 + .../__tests__/non-task-provider-usage.test.ts | 35 +++ .../server/__tests__/opencode-runtime.test.ts | 138 +++++++++ .../src/server/non-task-provider-usage.ts | 8 +- .../src/server/opencode-runtime.ts | 138 +++++---- .../cloudflare-opencode-provider.test.ts | 131 +++++++++ .../src/__tests__/inference-gateway.test.ts | 100 +++++++ .../types/src/cloudflare-opencode-provider.ts | 179 ++++++++++++ packages/types/src/index.ts | 1 + packages/types/src/inference-gateway.ts | 121 ++++++++ .../types/src/model-provider-config.test.ts | 265 +++++++++++++++++- packages/types/src/model-provider-config.ts | 84 ++++++ packages/types/src/task-models.test.ts | 14 + packages/types/src/task-models.ts | 4 + 34 files changed, 1868 insertions(+), 100 deletions(-) create mode 100644 apps/docs/providers/inference/cloudflare-ai-gateway.mdx create mode 100644 apps/docs/providers/inference/cloudflare-workers-ai.mdx create mode 100644 packages/types/src/__tests__/cloudflare-opencode-provider.test.ts create mode 100644 packages/types/src/cloudflare-opencode-provider.ts diff --git a/README.md b/README.md index ef1567dd79..c1d38b8b9b 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. @@ -243,7 +244,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 2557bca32e..0ff9f9130c 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -407,11 +407,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 and gateway IDs +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` @@ -433,8 +441,8 @@ 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 +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. 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 30ebce9de7..23ee46950e 100644 --- a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts +++ b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts @@ -125,6 +125,8 @@ describe('inference gateway', () => { vi.clearAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); + delete process.env.AWS_BEARER_TOKEN_BEDROCK; + delete process.env.AWS_REGION; // The Roomote trial key resolution is cached with a TTL; drop it so each // test observes its own mockResolveModelProviderEnvValue behavior. resetRoomoteInferenceKeyCache(); @@ -1504,4 +1506,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 a02fa8375f..2b02dc33b1 100644 --- a/apps/api/src/handlers/inference/index.ts +++ b/apps/api/src/handlers/inference/index.ts @@ -3,6 +3,7 @@ import { Hono } from 'hono'; import { formatSingleLineLog, rebaseRoomoteModelIdToUpstream, + rewriteCloudflareAiGatewayRequestBody, ROOMOTE_INFERENCE_PROVIDER_ID, } from '@roomote/types'; import { @@ -452,6 +453,14 @@ inference.on(['POST', 'GET'], '/:provider/*', async (c) => { } } + // Cloudflare /ai/v1 expects models.dev's hosted Workers AI slugs without + // the `workers-ai/` catalog namespace the AI Gateway provider uses. + if (providerId === 'cloudflare-ai-gateway' && method === 'POST') { + const bodyText = await c.req.text(); + requestBody = rewriteCloudflareAiGatewayRequestBody(bodyText); + useDuplexHalf = false; + } + // Roomote model ids are an aliased namespace over OpenRouter; rewrite a // catalog-id model reference onto the upstream slug OpenRouter expects. if (providerId === ROOMOTE_INFERENCE_PROVIDER_ID && method === 'POST') { diff --git a/apps/api/src/handlers/inference/registry.ts b/apps/api/src/handlers/inference/registry.ts index d29f914cbb..d27d59c58b 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, ROOMOTE_INFERENCE_PROVIDER_ID, @@ -126,23 +127,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 8ecd68bf24..f95a410627 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -78,6 +78,8 @@ "providers/inference/azure-foundry", "providers/inference/azure-openai", "providers/inference/baseten", + "providers/inference/cloudflare-ai-gateway", + "providers/inference/cloudflare-workers-ai", "providers/inference/chatgpt", "providers/inference/deepseek", "providers/inference/github-copilot", diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index ef1dc20123..9f1cad7fc7 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -213,7 +213,12 @@ manifest changes. | `REQUESTY_API_KEY` | Provider key | Requesty API key. Can also be saved from **Settings > Models**. | | `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. | +| `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. | | `DEEPSEEK_API_KEY` | Provider key | DeepSeek API key. Can also be saved from **Settings > Models**. | | `OPENAI_API_KEY` | Provider key | OpenAI API key. | | `AZURE_API_KEY` | Provider key | Azure OpenAI API key. | diff --git a/apps/docs/models.mdx b/apps/docs/models.mdx index 87e26d3c2c..273dc807a8 100644 --- a/apps/docs/models.mdx +++ b/apps/docs/models.mdx @@ -73,6 +73,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 | | [DeepSeek](/providers/inference/deepseek) | DeepSeek API key | DeepSeek platform balance | | [Google Gemini](/providers/inference/google-gemini) | Google AI Studio key | Google Cloud project | | [MiniMax](/providers/inference/minimax) | MiniMax API key | MiniMax account | 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 0000000000..d043c8eb17 --- /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 0000000000..76c91254f7 --- /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 2197bcdcc5..e3f85a68bf 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx @@ -27,6 +27,8 @@ describe('SetupDocs', () => { ['azure-cognitive-services', 'azure-foundry'], ['baseten', 'baseten'], ['chatgpt', 'chatgpt'], + ['cloudflare-ai-gateway', 'cloudflare-ai-gateway'], + ['cloudflare-workers-ai', 'cloudflare-workers-ai'], ['deepseek', 'deepseek'], ['github-copilot', 'github-copilot'], ['google', 'google-gemini'], diff --git a/apps/web/src/app/(onboarding)/setup/setup-docs.ts b/apps/web/src/app/(onboarding)/setup/setup-docs.ts index 76ad5a7e65..badefef694 100644 --- a/apps/web/src/app/(onboarding)/setup/setup-docs.ts +++ b/apps/web/src/app/(onboarding)/setup/setup-docs.ts @@ -30,6 +30,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', deepseek: 'providers/inference/deepseek', 'github-copilot': 'providers/inference/github-copilot', google: 'providers/inference/google-gemini', 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 5223b3b20c..672f021839 100644 --- a/apps/web/src/trpc/commands/task-models/index.test.ts +++ b/apps/web/src/trpc/commands/task-models/index.test.ts @@ -1,7 +1,9 @@ import { DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES, + getSetupModelProviderAdditionalEnvFields, DEFAULT_TASK_MODEL_ID, normalizeTaskModelId, + SETUP_MODEL_PROVIDER_CATALOG, TASK_MODEL_ROLE_DESCRIPTORS, TASK_MODEL_ROLES, } from '@roomote/types'; @@ -153,6 +155,11 @@ const PROVIDER_ENV_VAR_NAMES = [ 'GEMINI_API_KEY', 'OLLAMA_BASE_URL', 'VLLM_BASE_URL', + 'CLOUDFLARE_AI_GATEWAY_API_TOKEN', + 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID', + 'CLOUDFLARE_AI_GATEWAY_ID', + 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', 'R_MODEL', ] as const; const fetchMock = vi.fn(); @@ -1929,17 +1936,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 63f84c9709..6aceb6f7b9 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 @@ -97,6 +97,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('strips the requesty/ prefix for Requesty routed models', () => { expect(resolveModelsDevSlug('requesty/gpt-5.6-terra@eu')).toBe( 'gpt-5.6-terra@eu', 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 44c50658a2..ef263fe3fe 100644 --- a/apps/web/src/trpc/commands/task-models/models-dev.ts +++ b/apps/web/src/trpc/commands/task-models/models-dev.ts @@ -258,7 +258,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. * Kimi for Coding ids map onto the provider id models.dev renamed it to. diff --git a/apps/worker/src/run-task/agent-home.test.ts b/apps/worker/src/run-task/agent-home.test.ts index 018f4317d6..42b5f61658 100644 --- a/apps/worker/src/run-task/agent-home.test.ts +++ b/apps/worker/src/run-task/agent-home.test.ts @@ -996,6 +996,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 6c9757c565..5d346973ec 100644 --- a/apps/worker/src/run-task/agent-home.ts +++ b/apps/worker/src/run-task/agent-home.ts @@ -53,6 +53,7 @@ import { mergeBedrockMantleOpenAiProviderConfig, mergeBedrockMantleProviderConfig, mergeCatalogProviderCredentialConfig, + mergeCloudflareOpenCodeProviderConfig, mergeKimiForCodingProviderConfig, mergeOpenAiCompatibleProviderConfig, mergeOpenCodeModelReasoningOptions, @@ -64,6 +65,7 @@ import { parseTaskModelCosts, renderManualSkillMarkdown, resolveOpenRouterVariantModelAlias, + rewriteCloudflareOpenCodeModelId, toBedrockMantleRuntimeModelId, OPENCODE_ARCHITECT_AGENT, TASK_MODEL_CONTEXT_WINDOWS_ENV_VAR_NAME, @@ -1000,6 +1002,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 @@ -1562,43 +1570,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; @@ -1785,25 +1793,32 @@ function resolveModelBackedOpenCodeConfig( // Binds the keys OpenCode would not find under its catalog's env var // names. Gateway mode replaces the key and base URL just above. mergeCatalogProviderCredentialConfig( - mergeAmazonBedrockProviderConfig( - mergeBedrockMantleProviderConfig( - mergeBedrockMantleOpenAiProviderConfig( - mergeOpenAiCompatibleProviderConfig( - // Registered in full so it does not depend on OpenCode's - // runtime catalog; the gateway rebase below still replaces - // the base URL and credential in gateway mode. - mergeKimiForCodingProviderConfig( - mergeOpenRouterVariantAliasModels( - providerModelConfig, - variantAliases, + // Registered in full so neither Cloudflare provider depends on + // OpenCode's runtime catalog; the gateway rebase below still + // replaces the base URL and credential in gateway mode. + mergeCloudflareOpenCodeProviderConfig( + mergeAmazonBedrockProviderConfig( + mergeBedrockMantleProviderConfig( + mergeBedrockMantleOpenAiProviderConfig( + mergeOpenAiCompatibleProviderConfig( + // Registered in full so it does not depend on OpenCode's + // runtime catalog; the gateway rebase below still replaces + // the base URL and credential in gateway mode. + mergeKimiForCodingProviderConfig( + mergeOpenRouterVariantAliasModels( + providerModelConfig, + variantAliases, + ), + configuredModelIds, ), - configuredModelIds, + runtimeEnv, + openAiCompatibleModelIds, + visionModel ?? effectiveCodingModel, + modelContextWindows, + modelCosts, ), runtimeEnv, - openAiCompatibleModelIds, - visionModel ?? effectiveCodingModel, - modelContextWindows, - modelCosts, + configuredModelIds, ), runtimeEnv, configuredModelIds, @@ -1977,7 +1992,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 7a068a7f99..3bc9269965 100644 --- a/deploy/compose/docker-compose.prod.yml +++ b/deploy/compose/docker-compose.prod.yml @@ -104,6 +104,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 30437e6f25..a0544880c1 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -57,6 +57,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_ADDITIONAL_APP_SLUGS: ${R_GITHUB_ADDITIONAL_APP_SLUGS:-} diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml index 4fa82fe21d..dcf2a9a6d6 100644 --- a/docker-compose.self-host.yml +++ b/docker-compose.self-host.yml @@ -84,6 +84,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 d3d1bc9036..9b7fc5e323 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 d37ece8a7e..332bd80f2d 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 @@ -2735,6 +2735,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('uses the deployment primary model for text when requested', 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 6aa5c54f3b..9e4daba994 100644 --- a/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts +++ b/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts @@ -109,6 +109,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', @@ -520,6 +573,91 @@ describe('buildOpenCodeCliEnv', () => { }); }); + it('adds the Cloudflare AI Gateway registration to operator-supplied config content', () => { + const env = buildOpenCodeCliEnv({ + R_MODEL: 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'my_gateway', + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + model: 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + }), + }); + const config = JSON.parse(env.OPENCODE_CONFIG_CONTENT ?? '{}') as { + provider?: 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' }, + }, + }); + }); + + it('merges configured reasoning for Cloudflare AI Gateway models into operator-supplied config content', () => { + const env = buildOpenCodeCliEnv( + { + R_MODEL: 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2', + R_MODEL_REASONING_EFFORT: 'high', + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'my_gateway', + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + model: 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2', + }), + }, + { preserveReasoning: true }, + ); + const config = JSON.parse(env.OPENCODE_CONFIG_CONTENT ?? '{}') as { + provider?: Record< + string, + { models?: Record }> } + >; + }; + + const models = config.provider?.['cloudflare-ai-gateway']?.models ?? {}; + // The reasoning options must land under the same rewritten model id the + // provider registration uses, or OpenCode never applies them. + expect(models['@cf/zai-org/glm-5.2']?.options).toMatchObject({ + // Cloudflare /ai/v1 is OpenAI-compatible, so the option key is + // `reasoningEffort`, not OpenRouter's `reasoning.effort`. + reasoningEffort: 'high', + }); + }); + + it('adds the Cloudflare Workers AI registration to operator-supplied config content', () => { + 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', + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + model: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + }), + }); + const config = JSON.parse(env.OPENCODE_CONFIG_CONTENT ?? '{}') as { + provider?: Record< + string, + { options?: Record } & 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(); + }); + it('registers bearer-token credentials on the native Bedrock provider', () => { const env = buildOpenCodeCliEnv({ R_MODEL: 'amazon-bedrock/anthropic.claude-sonnet-5-v1:0', 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 cbbf6485b9..f616c4216e 100644 --- a/packages/cloud-agents/src/server/non-task-provider-usage.ts +++ b/packages/cloud-agents/src/server/non-task-provider-usage.ts @@ -15,6 +15,7 @@ import { isInferenceCreditsExhaustedError, isOpenRouterInFlightBudgetError, isReasoningEffort, + rewriteCloudflareOpenCodeModelId, toBedrockMantleRuntimeModelId, type ReasoningEffort, } from '@roomote/types'; @@ -818,6 +819,9 @@ function splitOpenCodeModelId(model: string): { providerID: string; modelID: string; } { + model = rewriteCloudflareOpenCodeModelId( + toBedrockMantleRuntimeModelId(model), + ); const separatorIndex = model.indexOf('/'); if (separatorIndex <= 0 || separatorIndex === model.length - 1) { @@ -1038,7 +1042,9 @@ async function resolveNonTaskModelRuntime( // 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), + ), catalogModelId: 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 diff --git a/packages/cloud-agents/src/server/opencode-runtime.ts b/packages/cloud-agents/src/server/opencode-runtime.ts index 77cfaa945a..a52ba2d67f 100644 --- a/packages/cloud-agents/src/server/opencode-runtime.ts +++ b/packages/cloud-agents/src/server/opencode-runtime.ts @@ -15,12 +15,14 @@ import { mergeBedrockMantleOpenAiProviderConfig, mergeBedrockMantleProviderConfig, mergeCatalogProviderCredentialConfig, + mergeCloudflareOpenCodeProviderConfig, mergeKimiForCodingProviderConfig, mergeOpenAiCompatibleProviderConfig, mergeOpenCodeModelReasoningOptions, mergeOpenCodeChatGptFastModeOptions, mergeOpenRouterVariantAliasModels, normalizeOptionalReasoningEffort, + rewriteCloudflareOpenCodeModelId, SETTINGS_ONLY_MODEL_PROVIDER_ENV_VAR_NAMES, stripOpenCodeModelReasoningOptions, toBedrockMantleRuntimeModelId, @@ -79,24 +81,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( @@ -144,9 +152,11 @@ function buildModelBackedOpenCodeConfigContent( } if (options.reasoningOverride) { - const overrideModel = collectOpenRouterVariantModelAlias( - variantAliases, - toBedrockMantleRuntimeModelId(options.reasoningOverride.model), + const overrideModel = rewriteCloudflareOpenCodeModelId( + collectOpenRouterVariantModelAlias( + variantAliases, + toBedrockMantleRuntimeModelId(options.reasoningOverride.model), + ), ); providerReasoningConfig = mergeOpenCodeModelReasoningOptions( providerReasoningConfig, @@ -168,31 +178,35 @@ 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( - // Kimi for Coding is registered in full rather than left to - // OpenCode's runtime catalog, which has renamed the provider id. - mergeKimiForCodingProviderConfig( - // Providers whose key OpenCode would not find under the env var - // its catalog names (Z.AI, Z.AI Coding Plan, OpenCode Go). - mergeCatalogProviderCredentialConfig( - mergeOpenRouterVariantAliasModels( - providerModelConfig, - variantAliases, + const providerConfig = mergeCloudflareOpenCodeProviderConfig( + mergeAmazonBedrockProviderConfig( + mergeBedrockMantleProviderConfig( + mergeBedrockMantleOpenAiProviderConfig( + mergeOpenAiCompatibleProviderConfig( + // Kimi for Coding is registered in full rather than left to + // OpenCode's runtime catalog, which has renamed the provider id. + mergeKimiForCodingProviderConfig( + // Providers whose key OpenCode would not find under the env var + // its catalog names (Z.AI, Z.AI Coding Plan, OpenCode Go). + mergeCatalogProviderCredentialConfig( + mergeOpenRouterVariantAliasModels( + providerModelConfig, + variantAliases, + ), + env, + configuredModelIds, ), - env, configuredModelIds, ), + env, configuredModelIds, + visionModel, + {}, + {}, + { assumeImageSupport: options.promptOnlySubagents }, ), env, configuredModelIds, - visionModel, - {}, - {}, - { assumeImageSupport: options.promptOnlySubagents }, ), env, configuredModelIds, @@ -413,9 +427,10 @@ function toRestrictedNonTaskConfigContent( } /** - * Merges the Bedrock provider registrations for the env's role models into an - * operator-supplied config content string. Malformed content is returned - * unchanged — `toRestrictedNonTaskConfigContent` already fails it closed. + * Merges the Bedrock, Kimi, and Cloudflare provider registrations for the + * env's role models into an operator-supplied config content string. + * Malformed content is returned unchanged; + * `toRestrictedNonTaskConfigContent` already fails it closed. */ function mergeBedrockRegistrationsIntoConfigContent( configContent: string, @@ -429,7 +444,9 @@ function mergeBedrockRegistrationsIntoConfigContent( (modelId): modelId is string => Boolean(modelId) && !isTaskModelIdDisabled(modelId!), ) - .map(toBedrockMantleRuntimeModelId); + .map((modelId) => + rewriteCloudflareOpenCodeModelId(toBedrockMantleRuntimeModelId(modelId)), + ); if (roleModelIds.length === 0) { return configContent; @@ -453,15 +470,19 @@ function mergeBedrockRegistrationsIntoConfigContent( !Array.isArray(config.provider) ? (config.provider as Record) : {}; - const provider = mergeAmazonBedrockProviderConfig( - mergeBedrockMantleProviderConfig( - mergeBedrockMantleOpenAiProviderConfig( - mergeKimiForCodingProviderConfig( - mergeCatalogProviderCredentialConfig( - existingProvider, - env, + const provider = mergeCloudflareOpenCodeProviderConfig( + mergeAmazonBedrockProviderConfig( + mergeBedrockMantleProviderConfig( + mergeBedrockMantleOpenAiProviderConfig( + mergeKimiForCodingProviderConfig( + mergeCatalogProviderCredentialConfig( + existingProvider, + env, + roleModelIds, + ), roleModelIds, ), + env, roleModelIds, ), env, @@ -533,7 +554,12 @@ function mergeReasoningIntoConfigContent( } const model = collectOpenRouterVariantModelAlias( variantAliases, - toBedrockMantleRuntimeModelId(rawModel), + // Same rewrite the provider registration in + // `mergeBedrockRegistrationsIntoConfigContent` applies: reasoning + // options must merge under the id OpenCode's catalog registered. + rewriteCloudflareOpenCodeModelId( + toBedrockMantleRuntimeModelId(rawModel), + ), ); provider = mergeOpenCodeModelReasoningOptions( provider, @@ -544,7 +570,9 @@ function mergeReasoningIntoConfigContent( if (reasoningOverride) { const overrideModel = collectOpenRouterVariantModelAlias( variantAliases, - toBedrockMantleRuntimeModelId(reasoningOverride.model), + rewriteCloudflareOpenCodeModelId( + toBedrockMantleRuntimeModelId(reasoningOverride.model), + ), ); provider = mergeOpenCodeModelReasoningOptions( provider, @@ -613,15 +641,12 @@ export function buildOpenCodeCliEnv( env.OPENCODE_CONFIG_CONTENT = modelBackedConfigContent; } } else { - // Operator-supplied config skips the model-backed builder, but the role - // models still need their Bedrock providers registered — otherwise a - // Bedrock helper model fails with ProviderModelNotFoundError whenever a - // deployment also sets OPENCODE_CONFIG_CONTENT. - env.OPENCODE_CONFIG_CONTENT = mergeBedrockRegistrationsIntoConfigContent( - env.OPENCODE_CONFIG_CONTENT, - env, - ); - + // Operator-supplied config skips the model-backed builder. Reasoning is + // merged BEFORE the provider registrations, mirroring the model-backed + // order: a registration pass that ran first would create each model + // entry and `mergeOpenCodeModelReasoningOptions` then skips existing + // entries, silently dropping the configured effort (matters for AI + // Gateway Workers AI models whose ids are rewritten at registration). if (options.preserveReasoning) { env.OPENCODE_CONFIG_CONTENT = mergeReasoningIntoConfigContent( env.OPENCODE_CONFIG_CONTENT, @@ -629,6 +654,15 @@ export function buildOpenCodeCliEnv( options.reasoningOverride, ); } + + // The role models still need their Bedrock, Kimi, and Cloudflare + // providers registered (otherwise a helper model fails with + // ProviderModelNotFoundError, or OpenCode's default Cloudflare catalog + // config, whenever a deployment also sets OPENCODE_CONFIG_CONTENT). + env.OPENCODE_CONFIG_CONTENT = mergeBedrockRegistrationsIntoConfigContent( + env.OPENCODE_CONFIG_CONTENT, + env, + ); } // Applied unconditionally, after any operator-supplied config content is diff --git a/packages/types/src/__tests__/cloudflare-opencode-provider.test.ts b/packages/types/src/__tests__/cloudflare-opencode-provider.test.ts new file mode 100644 index 0000000000..6881ae28bd --- /dev/null +++ b/packages/types/src/__tests__/cloudflare-opencode-provider.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeCloudflareOpenCodeProviderConfig } from '../cloudflare-opencode-provider'; + +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/__tests__/inference-gateway.test.ts b/packages/types/src/__tests__/inference-gateway.test.ts index 33a0c5e457..ee5534eaee 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'; @@ -359,4 +362,101 @@ 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', + }, + ], + openCodeBaseUrlSuffix: '/v1', + }); + expect(provider?.allowedPaths).toEqual( + expect.arrayContaining([ + '/v1/chat/completions', + '/v1/embeddings', + '/v1/responses', + ]), + ); + 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' }, + 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/cloudflare-opencode-provider.ts b/packages/types/src/cloudflare-opencode-provider.ts new file mode 100644 index 0000000000..e27e29b748 --- /dev/null +++ b/packages/types/src/cloudflare-opencode-provider.ts @@ -0,0 +1,179 @@ +import { + getInferenceGatewayProvider, + INFERENCE_GATEWAY_IDENTITY_PATTERN, + INFERENCE_GATEWAY_RESOURCE_PATTERN, + toCloudflareAiGatewayUpstreamModelId, + type InferenceGatewayProvider, +} from './inference-gateway'; +import { getSetupModelProvider } from './model-provider-config'; + +/** + * Cloudflare OpenCode wiring shared by the task worker and the non-task + * helper servers. + * + * OpenCode's models.dev catalog is not sufficient for either Cloudflare + * inference provider: the `cloudflare-ai-gateway` catalog entry would resolve + * to a package that ignores Roomote's `/v1/chat/completions` route shape, and + * `cloudflare-workers-ai` is not covered for the credential names Roomote + * stores. Both are therefore registered in full (npm package, base URL, + * credential env var, header, and model entries) against Cloudflare's + * OpenAI-compatible `/ai/v1` surface, using Roomote's namespaced env vars + * instead of any shared `CLOUDFLARE_*` name the runtime catalog assumes. + * + * models.dev also stores hosted Workers AI models under a `workers-ai/` + * namespace on the AI Gateway provider, while Cloudflare's `/ai/v1` expects + * the `@cf/...` id with that namespace removed — so model ids are rewritten + * here before they land in the config. + */ + +const CLOUDFLARE_AI_GATEWAY_OPENCODE_PROVIDER_ID = 'cloudflare-ai-gateway'; +const CLOUDFLARE_WORKERS_AI_OPENCODE_PROVIDER_ID = 'cloudflare-workers-ai'; + +const CLOUDFLARE_OPENCODE_PROVIDER_IDS = [ + CLOUDFLARE_AI_GATEWAY_OPENCODE_PROVIDER_ID, + CLOUDFLARE_WORKERS_AI_OPENCODE_PROVIDER_ID, +] as const; + +const CLOUDFLARE_OPENCODE_NPM_PACKAGE = '@ai-sdk/openai-compatible'; + +type RuntimeEnv = Readonly>; + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? { ...(value as Record) } + : {}; +} + +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_OPENCODE_PROVIDER_ID + ? 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: CLOUDFLARE_OPENCODE_NPM_PACKAGE, + 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/index.ts b/packages/types/src/index.ts index f725128494..faea9a9885 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -59,6 +59,7 @@ export * from './logging'; export * from './llm-usage'; export * from './bedrock-opencode-provider'; export * from './catalog-provider-credentials'; +export * from './cloudflare-opencode-provider'; export * from './kimi-for-coding-opencode-provider'; export * from './inference-gateway'; export * from './judgment-model'; diff --git a/packages/types/src/inference-gateway.ts b/packages/types/src/inference-gateway.ts index 8c6232b0bc..8847071f81 100644 --- a/packages/types/src/inference-gateway.ts +++ b/packages/types/src/inference-gateway.ts @@ -49,6 +49,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'; @@ -163,6 +241,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. */ @@ -359,6 +446,40 @@ 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, + ], + 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, + ], + openCodeBaseUrlSuffix: '/v1', + }, { id: 'deepseek', name: 'DeepSeek', diff --git a/packages/types/src/model-provider-config.test.ts b/packages/types/src/model-provider-config.test.ts index e465c4f623..85c863fbeb 100644 --- a/packages/types/src/model-provider-config.test.ts +++ b/packages/types/src/model-provider-config.test.ts @@ -353,8 +353,17 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { 'github-copilot', 'chatgpt', ] as const; + // Cloudflare AI Gateway routes the 5.6 family but not the 6-series. + const pairedGpt56ProviderIds: readonly string[] = [ + ...pairedGpt6ProviderIds, + 'cloudflare-ai-gateway', + ]; const gpt6LunaProviderIds = [...pairedGpt6ProviderIds, 'opencode-go']; - const gpt56LunaProviderIds = [...pairedGpt6ProviderIds, 'opencode-go']; + const gpt56LunaProviderIds = [ + ...pairedGpt6ProviderIds, + 'opencode-go', + 'cloudflare-ai-gateway', + ]; it('exposes the supported setup providers for the onboarding UI', () => { expect(userSelectableProviders.map((provider) => provider.id)).toEqual([ @@ -363,6 +372,8 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { 'requesty', 'baseten', 'togetherai', + 'cloudflare-ai-gateway', + 'cloudflare-workers-ai', 'deepseek', 'openai', 'azure', @@ -515,6 +526,10 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { ['openrouter', 'openrouter/anthropic/claude-opus-5.5'], ['vercel', 'vercel/anthropic/claude-opus-5.5'], ['requesty', 'requesty/anthropic/claude-opus-5-5'], + [ + 'cloudflare-ai-gateway', + 'cloudflare-ai-gateway/anthropic/claude-opus-5-5', + ], ['azure', 'azure/claude-opus-5-5'], [ 'azure-cognitive-services', @@ -534,6 +549,7 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { ['requesty', 'requesty/kimi-k3'], ['baseten', 'baseten/moonshotai/Kimi-K3'], ['togetherai', 'togetherai/moonshotai/Kimi-K3'], + ['cloudflare-ai-gateway', 'cloudflare-ai-gateway/moonshotai/kimi-k3'], ['moonshotai', 'moonshotai/kimi-k3'], ['kimi-for-coding', 'kimi-for-coding/k3'], ['opencode', 'opencode/kimi-k3'], @@ -592,6 +608,14 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { ['openrouter', 'openrouter/z-ai/glm-5.3'], ['vercel', 'vercel/zai/glm-5.3'], ['requesty', 'requesty/glm-5.3'], + [ + 'cloudflare-ai-gateway', + 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.3', + ], + [ + 'cloudflare-workers-ai', + 'cloudflare-workers-ai/@cf/zai-org/glm-5.3', + ], ['opencode-go', 'opencode-go/glm-5.3'], ['zai', 'zai/glm-5.3'], ['zai-coding-plan', 'zai-coding-plan/glm-5.3'], @@ -603,6 +627,14 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { ['openrouter', 'openrouter/z-ai/glm-5.3-flash'], ['vercel', 'vercel/zai/glm-5.3-flash'], ['requesty', 'requesty/glm-5.3-flash'], + [ + 'cloudflare-ai-gateway', + 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.3-flash', + ], + [ + 'cloudflare-workers-ai', + 'cloudflare-workers-ai/@cf/zai-org/glm-5.3-flash', + ], ['opencode-go', 'opencode-go/glm-5.3-flash'], ['zai', 'zai/glm-5.3-flash'], ['zai-coding-plan', 'zai-coding-plan/glm-5.3-flash'], @@ -613,6 +645,14 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { [ ['baseten', 'baseten/zai-org/GLM-5.2'], ['togetherai', 'togetherai/zai-org/GLM-5.2'], + [ + 'cloudflare-ai-gateway', + 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2', + ], + [ + 'cloudflare-workers-ai', + 'cloudflare-workers-ai/@cf/zai-org/glm-5.2', + ], ['opencode', 'opencode/glm-5.2'], ], ], @@ -652,7 +692,7 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { { displayName: 'GPT 5.6 Sol', modelId: 'gpt-5.6-sol', - providerIds: pairedGpt6ProviderIds, + providerIds: pairedGpt56ProviderIds, }, { displayName: 'GPT-6 Sol', @@ -662,7 +702,7 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { { displayName: 'GPT 5.6 Terra', modelId: 'gpt-5.6-terra', - providerIds: pairedGpt6ProviderIds, + providerIds: pairedGpt56ProviderIds, }, { displayName: 'GPT 5.6 Luna', @@ -690,6 +730,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}` }, { @@ -722,7 +766,10 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { (suggestion) => suggestion.displayName === 'GPT 5.6 Luna', ); - if (!luna) { + // Providers with explicit role recommendations intentionally choose + // their own coding default and non-coding roles (Cloudflare AI Gateway + // recommends a stronger planning model than its coding default). + if (!luna || 'recommendedRoleModels' in provider) { continue; } @@ -2176,3 +2223,213 @@ 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.3-flash', + 'cloudflare-workers-ai/@cf/zai-org/glm-5.3', + '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 5217cd419f..94305079ce 100644 --- a/packages/types/src/model-provider-config.ts +++ b/packages/types/src/model-provider-config.ts @@ -618,6 +618,90 @@ 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', + defaultRoomoteModel: 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + 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', + }, + ], + 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-5': 'cloudflare-ai-gateway/anthropic/claude-opus-5-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-3-flash': + 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.3-flash', + 'glm-5-3': 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.3', + '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-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', + defaultRoomoteModel: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + 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', + }, + ], + suggestedTaskModels: mapRecommendedTaskModels({ + 'glm-5-3-flash': 'cloudflare-workers-ai/@cf/zai-org/glm-5.3-flash', + 'glm-5-3': 'cloudflare-workers-ai/@cf/zai-org/glm-5.3', + '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', + }), + }, { // Provider id and model slugs match models.dev / OpenCode's direct // DeepSeek catalog. diff --git a/packages/types/src/task-models.test.ts b/packages/types/src/task-models.test.ts index a700eb21dc..eb70ceff9c 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 0fdf415e03..e8fb29b50d 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', 'deepseek', 'openai', 'azure', @@ -82,6 +84,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 = [ From 71d2ce7ab80357bfc30e256a2f25d269194f55f2 Mon Sep 17 00:00:00 2001 From: Pride Musvaire Date: Sat, 26 Sep 2026 23:09:15 +0200 Subject: [PATCH 2/2] Keep catalog provider env keys alongside custom R_MODEL_ENV_KEYS names. R_MODEL_ENV_KEYS adds custom provider credentials; it does not replace the catalog-derived set, so a Cloudflare model still forwards its required account and gateway values to helper inference when custom key names are configured. --- .../db/src/lib/model-runtime-config.test.ts | 31 +++++++++++++++++++ packages/db/src/lib/model-runtime-config.ts | 17 ++++++---- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/db/src/lib/model-runtime-config.test.ts b/packages/db/src/lib/model-runtime-config.test.ts index d6b2263551..14f48998e9 100644 --- a/packages/db/src/lib/model-runtime-config.test.ts +++ b/packages/db/src/lib/model-runtime-config.test.ts @@ -306,6 +306,37 @@ describe('resolveEffectiveModelRuntimeEnv', () => { }); }); + it('keeps catalog provider values for the selected models alongside custom key names', async () => { + // R_MODEL_ENV_KEYS is an ADD mechanism for custom provider key names, not + // a replacement for the catalog-derived set: a Cloudflare model still + // needs its account and gateway values forwarded, or helper inference + // fails with a missing required header. + mockDeploymentSettingsFindFirst.mockResolvedValue({ + runtimeModelConfig: { + roomoteModel: 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + }, + }); + + const env = await resolveEffectiveModelRuntimeEnv({ + runtimeEnv: {}, + deploymentEnvVars: { + R_MODEL_ENV_KEYS: 'CUSTOM_LLM_TOKEN', + CUSTOM_LLM_TOKEN: 'saved-token', + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'cf-token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'default', + }, + }); + + expect(env).toMatchObject({ + R_MODEL: 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'default', + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'cf-token', + CUSTOM_LLM_TOKEN: 'saved-token', + }); + }); + it('falls back to persisted roomoteSmallModel when the env var is absent', async () => { mockDeploymentSettingsFindFirst.mockResolvedValue({ runtimeModelConfig: { diff --git a/packages/db/src/lib/model-runtime-config.ts b/packages/db/src/lib/model-runtime-config.ts index db77f26bce..698399a528 100644 --- a/packages/db/src/lib/model-runtime-config.ts +++ b/packages/db/src/lib/model-runtime-config.ts @@ -189,23 +189,28 @@ function resolveProviderKeyNames({ (envVarName) => !DISABLED_MODEL_PROVIDER_ENV_VAR_NAME_SET.has(envVarName), ); - if (configuredProviderKeys.length > 0) { - return configuredProviderKeys; - } - const providerIds = resolvedRoomoteModels.flatMap((model) => { const providerId = resolveSetupModelProviderIdFromModel(model); return providerId ? [providerId] : []; }); - - return [ + const catalogProviderKeys = [ ...new Set( providerIds.flatMap((providerId) => getModelProviderEnvKeyCandidates({ providerId }), ), ), ]; + + // R_MODEL_ENV_KEYS names extra custom provider credentials; it does not + // replace the catalog-derived set. Selected catalog models keep their own + // env keys (a Cloudflare model still needs its account and gateway values + // forwarded, or helper inference fails on a missing required header). + if (configuredProviderKeys.length > 0) { + return [...new Set([...configuredProviderKeys, ...catalogProviderKeys])]; + } + + return catalogProviderKeys; } /**