From 8f50209915b8b94a5d0073a5fe462cbc827f43e4 Mon Sep 17 00:00:00 2001 From: Pride Musvaire Date: Thu, 13 Aug 2026 08:10:13 +0800 Subject: [PATCH 1/6] [Feature] Add Cloudflare AI Gateway and Workers AI as separate providers Treat them as independent inference connections with distinct credentials, model prefixes, and docs so one Cloudflare token cannot satisfy both. --- README.md | 4 +- SELF_HOSTING.md | 5 + .../__tests__/inference-gateway.test.ts | 103 +++++++++ apps/api/src/handlers/inference/registry.ts | 40 +++- apps/docs/docs.json | 2 + apps/docs/environment-variables.mdx | 5 + apps/docs/models.mdx | 2 + .../inference/cloudflare-ai-gateway.mdx | 67 ++++++ .../inference/cloudflare-workers-ai.mdx | 67 ++++++ .../setup/SetupDocs.client.test.tsx | 2 + .../src/app/(onboarding)/setup/setup-docs.ts | 2 + .../commands/task-models/models-dev.test.ts | 19 ++ .../trpc/commands/task-models/models-dev.ts | 3 +- deploy/compose/docker-compose.prod.yml | 5 + docker-compose.production.yml | 5 + docker-compose.self-host.yml | 5 + .../src/__tests__/inference-gateway.test.ts | 63 ++++++ packages/types/src/inference-gateway.ts | 40 ++++ .../types/src/model-provider-config.test.ts | 212 ++++++++++++++++++ packages/types/src/model-provider-config.ts | 79 +++++++ packages/types/src/task-models.test.ts | 14 ++ packages/types/src/task-models.ts | 4 + 22 files changed, 743 insertions(+), 5 deletions(-) create mode 100644 apps/docs/providers/inference/cloudflare-ai-gateway.mdx create mode 100644 apps/docs/providers/inference/cloudflare-workers-ai.mdx diff --git a/README.md b/README.md index 58eea7b31..0a766db9e 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ cleans up after itself. the models included in your subscription. 2. **API keys (BYOK).** Paste a key from OpenRouter, Anthropic, OpenAI, xAI, Google Gemini, Amazon Bedrock, Vercel AI Gateway, + Cloudflare AI Gateway, Cloudflare Workers AI, Baseten, Together AI, Moonshot AI (Kimi), Kimi for Coding, MiniMax, Z.AI (including Coding Plan), OpenCode Zen / Go, or GitHub Copilot. @@ -232,7 +233,8 @@ it runs. **What models does it support?** Two options. Connect your ChatGPT Plus or Pro subscription directly (no API key needed), or paste an API key from OpenRouter, Anthropic, OpenAI, xAI, Google -Gemini, Amazon Bedrock, Vercel AI Gateway, Baseten, +Gemini, Amazon Bedrock, Vercel AI Gateway, Cloudflare AI Gateway, +Cloudflare Workers AI, Baseten, Together AI, Moonshot AI (Kimi), Kimi for Coding, MiniMax, Z.AI (including Coding Plan), OpenCode Zen / Go, or GitHub Copilot. diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 92530e03e..e49361ca7 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -401,6 +401,11 @@ common provider keys into worker containers: - `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` diff --git a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts index fc9a5f6b2..0a618a99a 100644 --- a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts +++ b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts @@ -116,6 +116,8 @@ describe('inference gateway', () => { vi.clearAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); + delete process.env.AWS_BEARER_TOKEN_BEDROCK; + delete process.env.AWS_REGION; mockFindTaskRun.mockResolvedValue({ id: 42 }); mockGetGitHubCopilotAccessToken.mockResolvedValue(null); mockGetFreshXaiAccessToken.mockResolvedValue(null); @@ -1311,4 +1313,105 @@ 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 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.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/registry.ts b/apps/api/src/handlers/inference/registry.ts index 27e8808c7..c3bbee679 100644 --- a/apps/api/src/handlers/inference/registry.ts +++ b/apps/api/src/handlers/inference/registry.ts @@ -79,23 +79,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_RESOURCE_PATTERN.test(value)) { + throw new Error( + `${spec.envVarName} must be a valid resource name 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 e61648c38..14d70310b 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -67,6 +67,8 @@ "providers/inference/azure-openai", "providers/inference/baseten", "providers/inference/chatgpt", + "providers/inference/cloudflare-ai-gateway", + "providers/inference/cloudflare-workers-ai", "providers/inference/github-copilot", "providers/inference/google-gemini", "providers/inference/kimi-for-coding", diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index eadbe990d..6f290b465 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -188,6 +188,11 @@ as per-task auth tokens or workspace paths. | `AI_GATEWAY_API_KEY` | Provider key | Vercel AI Gateway API key. | | `BASETEN_API_KEY` | Provider key | Baseten API key. | | `TOGETHER_API_KEY` | Provider key | Together AI API key. | +| `CLOUDFLARE_AI_GATEWAY_API_TOKEN` | Provider key | Cloudflare AI Gateway API token. Does not connect Workers AI. | +| `CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID` | Provider config | Cloudflare account ID for AI Gateway requests. | +| `CLOUDFLARE_AI_GATEWAY_ID` | Provider config | Cloudflare AI Gateway ID, for example `default`. | +| `CLOUDFLARE_WORKERS_AI_API_TOKEN` | Provider key | Cloudflare Workers AI API token. Does not connect AI Gateway. | +| `CLOUDFLARE_WORKERS_AI_ACCOUNT_ID` | Provider config | Cloudflare account ID for Workers AI requests. A gateway ID is not used. | | `OPENAI_API_KEY` | Provider key | OpenAI API key. | | `AZURE_API_KEY` | Provider key | Azure OpenAI API key. | | `AZURE_RESOURCE_NAME` | Provider config | Azure OpenAI resource name, without the domain or URL. | diff --git a/apps/docs/models.mdx b/apps/docs/models.mdx index 11aa764ed..b412434a4 100644 --- a/apps/docs/models.mdx +++ b/apps/docs/models.mdx @@ -47,6 +47,8 @@ These connections use metered API billing or a provider-managed gateway: | [Azure AI Foundry](/providers/inference/azure-foundry) | Azure AI Services API key and resource name | Azure subscription | | [Azure OpenAI](/providers/inference/azure-openai) | Azure OpenAI API key and resource name | Azure subscription | | [Baseten](/providers/inference/baseten) | Baseten API key | Baseten workspace | +| [Cloudflare AI Gateway](/providers/inference/cloudflare-ai-gateway) | Cloudflare API token, account ID, and gateway ID | Cloudflare account | +| [Cloudflare Workers AI](/providers/inference/cloudflare-workers-ai) | Cloudflare API token and account ID | Cloudflare account | | [Google Gemini](/providers/inference/google-gemini) | Google AI Studio key | Google Cloud project | | [MiniMax](/providers/inference/minimax) | MiniMax API key | MiniMax account | | [Moonshot AI (Kimi)](/providers/inference/moonshot-ai) | Kimi Open Platform key | Moonshot Open Platform balance | diff --git a/apps/docs/providers/inference/cloudflare-ai-gateway.mdx b/apps/docs/providers/inference/cloudflare-ai-gateway.mdx new file mode 100644 index 000000000..6729ca3ae --- /dev/null +++ b/apps/docs/providers/inference/cloudflare-ai-gateway.mdx @@ -0,0 +1,67 @@ +--- +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 AI Gateway access. 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 AI + Gateway permission, not an unrelated Cloudflare global API key. +- **A model cannot be routed.** Check that the vendor is enabled on the + selected gateway and that stored keys or unified billing cover that model. +- **Workers AI models fail.** `@cf/` models through AI Gateway still need + Workers AI access on the token. Connecting Workers AI as its own provider is + a separate step. diff --git a/apps/docs/providers/inference/cloudflare-workers-ai.mdx b/apps/docs/providers/inference/cloudflare-workers-ai.mdx new file mode 100644 index 000000000..76c91254f --- /dev/null +++ b/apps/docs/providers/inference/cloudflare-workers-ai.mdx @@ -0,0 +1,67 @@ +--- +title: Cloudflare Workers AI +icon: 'https://unpkg.com/@lobehub/icons-static-svg@1.94.0/icons/cloudflare.svg' +description: Use Cloudflare-hosted Workers AI models for Roomote tasks. +--- + +Cloudflare Workers AI hosts open and specialized models on Cloudflare's +network. Roomote's direct provider exposes a curated `@cf/` subset suited to +coding and agent work. + +This is a separate connection from [Cloudflare AI Gateway](/providers/inference/cloudflare-ai-gateway). +Connecting Workers AI does not require a gateway ID and does not connect AI +Gateway. + +## Get credentials + +Create a [Cloudflare API token](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/) +with Workers AI access. Copy the account ID from the Cloudflare dashboard. A +gateway ID is not used for this provider. + +Make sure the account has Workers AI enabled for the models you select. See +the [Workers AI model catalog](https://developers.cloudflare.com/workers-ai/models/). + +## Configuration + +Add **Cloudflare Workers AI** in **Settings > Models**, then enter the API +token and account ID. Environment-variable configuration uses: + +```sh +CLOUDFLARE_WORKERS_AI_API_TOKEN=... +CLOUDFLARE_WORKERS_AI_ACCOUNT_ID=your-account-id +``` + +Roomote adds supported models with the `cloudflare-workers-ai/` prefix, using +Cloudflare-hosted `@cf/` catalog IDs. Enable the models you want and assign the +default coding and specialized roles. Provider model names are case-sensitive, +so use the IDs shown in Roomote rather than typing a similar slug from another +gateway. + +In gateway mode, the API token stays on the Roomote control plane. Requests are +proxied to Cloudflare's OpenAI-compatible +`/accounts//ai/v1` surface. No gateway header is sent. + +## Cost behavior + +Cloudflare charges the connected account according to the selected Workers AI +model. Roomote records usage and estimates cost when model metadata includes +pricing; Cloudflare billing and the [Workers AI pricing page](https://developers.cloudflare.com/workers-ai/platform/pricing/) +are authoritative. + +## Verify setup + +1. save the API token and account ID +2. confirm Workers AI models appear in **Settings > Models** +3. enable one `@cf/` model and assign it as the coding model +4. run a small task and confirm the request appears in Workers AI usage + +## Common issues + +- **Authentication fails.** Confirm the token has Workers AI permission and + belongs to the configured account. +- **A model ID is rejected.** Use the exact `cloudflare-workers-ai/@cf/...` ID + shown in Roomote. IDs from AI Gateway or another provider are not + interchangeable. +- **Requests mention a missing gateway.** You are calling the AI Gateway + provider, not Workers AI. Connect **Cloudflare Workers AI** when you want + hosted `@cf/` models without a gateway ID. diff --git a/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx b/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx index 3e9d4d21a..d0794992c 100644 --- a/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/SetupDocs.client.test.tsx @@ -45,6 +45,8 @@ describe('SetupDocs', () => { ['azure-cognitive-services', 'azure-foundry'], ['baseten', 'baseten'], ['chatgpt', 'chatgpt'], + ['cloudflare-ai-gateway', 'cloudflare-ai-gateway'], + ['cloudflare-workers-ai', 'cloudflare-workers-ai'], ['github-copilot', 'github-copilot'], ['google', 'google-gemini'], ['kimi-for-coding', 'kimi-for-coding'], diff --git a/apps/web/src/app/(onboarding)/setup/setup-docs.ts b/apps/web/src/app/(onboarding)/setup/setup-docs.ts index 50be7eaed..abd24dbc9 100644 --- a/apps/web/src/app/(onboarding)/setup/setup-docs.ts +++ b/apps/web/src/app/(onboarding)/setup/setup-docs.ts @@ -57,6 +57,8 @@ const MODEL_PROVIDER_DOC_PATHS: Partial< 'azure-cognitive-services': 'providers/inference/azure-foundry', baseten: 'providers/inference/baseten', chatgpt: 'providers/inference/chatgpt', + 'cloudflare-ai-gateway': 'providers/inference/cloudflare-ai-gateway', + 'cloudflare-workers-ai': 'providers/inference/cloudflare-workers-ai', 'github-copilot': 'providers/inference/github-copilot', google: 'providers/inference/google-gemini', 'kimi-for-coding': 'providers/inference/kimi-for-coding', diff --git a/apps/web/src/trpc/commands/task-models/models-dev.test.ts b/apps/web/src/trpc/commands/task-models/models-dev.test.ts index 5006dc6b2..b207a525a 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 @@ -68,6 +68,25 @@ describe('resolveModelsDevSlug', () => { ); }); + it('strips the cloudflare-ai-gateway/ prefix for AI Gateway routed models', () => { + expect( + resolveModelsDevSlug('cloudflare-ai-gateway/openai/gpt-5.6-terra'), + ).toBe('openai/gpt-5.6-terra'); + expect( + resolveModelsDevSlug( + 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2', + ), + ).toBe('workers-ai/@cf/zai-org/glm-5.2'); + }); + + it('strips the cloudflare-workers-ai/ prefix for hosted Workers AI models', () => { + expect( + resolveModelsDevSlug( + 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + ), + ).toBe('@cf/moonshotai/kimi-k2.7-code'); + }); + it('maps Bedrock Mantle model ids to their models.dev lab slugs', () => { expect( resolveModelsDevSlug('bedrock-mantle/anthropic.claude-haiku-4-5'), diff --git a/apps/web/src/trpc/commands/task-models/models-dev.ts b/apps/web/src/trpc/commands/task-models/models-dev.ts index 3a698e7b1..24f70e148 100644 --- a/apps/web/src/trpc/commands/task-models/models-dev.ts +++ b/apps/web/src/trpc/commands/task-models/models-dev.ts @@ -198,7 +198,8 @@ export async function fetchModelsDevCatalog( /** * Resolves the models.dev catalog slug for a Roomote task model id. * Strips a leading gateway provider prefix (`openrouter/`, `vercel/`, - * `requesty/`, `baseten/`, `togetherai/`) and any leading `~` alias marker. + * `requesty/`, `baseten/`, `togetherai/`, `cloudflare-ai-gateway/`, + * `cloudflare-workers-ai/`) and any leading `~` alias marker. * Mantle's `lab.model` identifiers are converted to models.dev's `lab/model` * slugs so metadata continues to resolve through the underlying model lab. */ diff --git a/deploy/compose/docker-compose.prod.yml b/deploy/compose/docker-compose.prod.yml index e89ad8164..eb84fe156 100644 --- a/deploy/compose/docker-compose.prod.yml +++ b/deploy/compose/docker-compose.prod.yml @@ -67,6 +67,11 @@ x-roomote-inference-env: &roomote-inference-env OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-} BASETEN_API_KEY: ${BASETEN_API_KEY:-} TOGETHER_API_KEY: ${TOGETHER_API_KEY:-} + CLOUDFLARE_AI_GATEWAY_API_TOKEN: ${CLOUDFLARE_AI_GATEWAY_API_TOKEN:-} + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: ${CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID:-} + CLOUDFLARE_AI_GATEWAY_ID: ${CLOUDFLARE_AI_GATEWAY_ID:-} + CLOUDFLARE_WORKERS_AI_API_TOKEN: ${CLOUDFLARE_WORKERS_AI_API_TOKEN:-} + CLOUDFLARE_WORKERS_AI_ACCOUNT_ID: ${CLOUDFLARE_WORKERS_AI_ACCOUNT_ID:-} x-roomote-web-env: &roomote-web-env <<: *roomote-inference-env diff --git a/docker-compose.production.yml b/docker-compose.production.yml index bdf31ef35..e130c1efd 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -55,6 +55,11 @@ x-roomote-production-env: &roomote-production-env OPENCODE_GO_API_KEY: ${OPENCODE_GO_API_KEY:-} BASETEN_API_KEY: ${BASETEN_API_KEY:-} TOGETHER_API_KEY: ${TOGETHER_API_KEY:-} + CLOUDFLARE_AI_GATEWAY_API_TOKEN: ${CLOUDFLARE_AI_GATEWAY_API_TOKEN:-} + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: ${CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID:-} + CLOUDFLARE_AI_GATEWAY_ID: ${CLOUDFLARE_AI_GATEWAY_ID:-} + CLOUDFLARE_WORKERS_AI_API_TOKEN: ${CLOUDFLARE_WORKERS_AI_API_TOKEN:-} + CLOUDFLARE_WORKERS_AI_ACCOUNT_ID: ${CLOUDFLARE_WORKERS_AI_ACCOUNT_ID:-} GITHUB_TOKEN: ${GITHUB_TOKEN:-} R_GITHUB_APP_SLUG: ${R_GITHUB_APP_SLUG:?R_GITHUB_APP_SLUG is required} R_GITHUB_APP_ID: ${R_GITHUB_APP_ID:-} diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml index 5bd8a29c1..cc467a018 100644 --- a/docker-compose.self-host.yml +++ b/docker-compose.self-host.yml @@ -53,6 +53,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/packages/types/src/__tests__/inference-gateway.test.ts b/packages/types/src/__tests__/inference-gateway.test.ts index 0798e555e..d69e77a94 100644 --- a/packages/types/src/__tests__/inference-gateway.test.ts +++ b/packages/types/src/__tests__/inference-gateway.test.ts @@ -318,4 +318,67 @@ 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/models', + ]), + ); + expect( + getInferenceGatewayProviderByEnvVarName('CLOUDFLARE_AI_GATEWAY_API_TOKEN') + ?.id, + ).toBe('cloudflare-ai-gateway'); + expect( + buildInferenceGatewayOpenCodeBaseUrl( + 'https://api.example.com/api/inference', + provider!, + ), + ).toBe('https://api.example.com/api/inference/cloudflare-ai-gateway/v1'); + }); + + it('registers Cloudflare Workers AI with account URL templating and no gateway id', () => { + const provider = getInferenceGatewayProvider('cloudflare-workers-ai'); + + expect(provider).toMatchObject({ + envVarNames: ['CLOUDFLARE_WORKERS_AI_API_TOKEN'], + upstreamBaseUrl: + 'https://api.cloudflare.com/client/v4/accounts/{resource}/ai', + resource: { envVarName: 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID' }, + authHeader: { name: 'authorization', scheme: 'bearer' }, + openCodeBaseUrlSuffix: '/v1', + }); + expect(provider?.requiredHeaders).toBeUndefined(); + expect(provider?.allowedPaths).toEqual( + expect.arrayContaining(['/v1/chat/completions', '/v1/embeddings']), + ); + 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'); + }); }); diff --git a/packages/types/src/inference-gateway.ts b/packages/types/src/inference-gateway.ts index c20812be7..5fe4494f9 100644 --- a/packages/types/src/inference-gateway.ts +++ b/packages/types/src/inference-gateway.ts @@ -160,6 +160,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. */ @@ -340,6 +349,37 @@ 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, + openCodeBaseUrlSuffix: '/v1', + }, { id: 'moonshotai', name: 'Moonshot AI', diff --git a/packages/types/src/model-provider-config.test.ts b/packages/types/src/model-provider-config.test.ts index 227c8511b..dbea72c0c 100644 --- a/packages/types/src/model-provider-config.test.ts +++ b/packages/types/src/model-provider-config.test.ts @@ -243,6 +243,8 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { 'requesty', 'baseten', 'togetherai', + 'cloudflare-ai-gateway', + 'cloudflare-workers-ai', 'openai', 'azure', 'azure-cognitive-services', @@ -350,6 +352,10 @@ describe('SETUP_MODEL_PROVIDER_CATALOG', () => { { providerId: 'requesty', modelId: 'requesty/kimi-k3' }, { providerId: 'baseten', modelId: 'baseten/moonshotai/Kimi-K3' }, { providerId: 'togetherai', modelId: 'togetherai/moonshotai/Kimi-K3' }, + { + providerId: 'cloudflare-ai-gateway', + modelId: 'cloudflare-ai-gateway/moonshotai/kimi-k3', + }, { providerId: 'moonshotai', modelId: 'moonshotai/kimi-k3' }, { providerId: 'kimi-for-coding', modelId: 'kimi-for-coding/k3' }, { providerId: 'opencode', modelId: 'opencode/kimi-k3' }, @@ -420,6 +426,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}` }, { @@ -1752,3 +1762,205 @@ 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?.additionalEnvFields).toEqual([ + { + envVarName: 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID', + label: 'Account ID', + secret: false, + required: true, + placeholder: 'your-account-id', + }, + { + envVarName: 'CLOUDFLARE_AI_GATEWAY_ID', + label: 'Gateway ID', + secret: false, + required: true, + placeholder: 'default', + }, + ]); + expect( + gatewayProvider?.suggestedTaskModels.map((model) => model.id), + ).toEqual( + expect.arrayContaining([ + 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + 'cloudflare-ai-gateway/anthropic/claude-sonnet-5', + 'cloudflare-ai-gateway/moonshotai/kimi-k3', + ]), + ); + expect( + gatewayProvider?.suggestedTaskModels.every((model) => + model.id.startsWith('cloudflare-ai-gateway/'), + ), + ).toBe(true); + + expect(workersProvider).toMatchObject({ + id: 'cloudflare-workers-ai', + label: 'Cloudflare Workers AI', + envVarName: 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + envVarLabel: 'API token', + authKind: 'api-key', + defaultRoomoteModel: + 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + }); + expect(workersProvider?.additionalEnvFields).toEqual([ + { + envVarName: 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', + label: 'Account ID', + secret: false, + required: true, + placeholder: 'your-account-id', + }, + ]); + expect( + workersProvider?.suggestedTaskModels.map((model) => model.id), + ).toEqual( + expect.arrayContaining([ + 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + 'cloudflare-workers-ai/@cf/zai-org/glm-5.2', + ]), + ); + expect( + workersProvider?.suggestedTaskModels.every((model) => + model.id.startsWith('cloudflare-workers-ai/'), + ), + ).toBe(true); + + expect(getModelProviderLabel('cloudflare-ai-gateway')).toBe( + 'Cloudflare AI Gateway', + ); + expect(getModelProviderLabel('cloudflare-workers-ai')).toBe( + 'Cloudflare Workers AI', + ); + expect( + resolveSetupModelProviderIdFromModel( + 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + ), + ).toBe('cloudflare-ai-gateway'); + expect( + resolveSetupModelProviderIdFromModel( + 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + ), + ).toBe('cloudflare-workers-ai'); + expect( + getModelProviderEnvKeyCandidates({ + providerId: 'cloudflare-ai-gateway', + }), + ).toEqual([ + 'CLOUDFLARE_AI_GATEWAY_API_TOKEN', + 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID', + 'CLOUDFLARE_AI_GATEWAY_ID', + ]); + expect( + getModelProviderEnvKeyCandidates({ + providerId: 'cloudflare-workers-ai', + }), + ).toEqual([ + 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', + ]); + expect(DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES).toContain( + 'CLOUDFLARE_AI_GATEWAY_API_TOKEN', + ); + expect(DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES).toContain( + 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + ); + expect(DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES).not.toContain( + 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID', + ); + expect(DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES).not.toContain( + 'CLOUDFLARE_AI_GATEWAY_ID', + ); + expect(DEFAULT_MODEL_PROVIDER_CREDENTIAL_ENV_VAR_NAMES).not.toContain( + 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', + ); + }); + + it('does not treat a complete AI Gateway config as Workers AI connectedness', () => { + const status = buildSetupModelStatus({ + runtimeEnv: { + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'cf-gateway-token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'default', + }, + }); + + expect( + status.providers.find( + (provider) => provider.id === 'cloudflare-ai-gateway', + ), + ).toMatchObject({ + runtimeApiKeySatisfied: true, + savedApiKeySatisfied: false, + }); + expect( + status.providers.find( + (provider) => provider.id === 'cloudflare-workers-ai', + ), + ).toMatchObject({ + runtimeApiKeySatisfied: false, + savedApiKeySatisfied: false, + }); + }); + + it('does not treat a complete Workers AI config as AI Gateway connectedness', () => { + const status = buildSetupModelStatus({ + persistedEnvVarNames: [ + 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', + ], + persistedEnvVarValues: { + CLOUDFLARE_WORKERS_AI_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + }, + }); + + expect( + status.providers.find( + (provider) => provider.id === 'cloudflare-workers-ai', + ), + ).toMatchObject({ + runtimeApiKeySatisfied: false, + savedApiKeySatisfied: true, + }); + expect( + status.providers.find( + (provider) => provider.id === 'cloudflare-ai-gateway', + ), + ).toMatchObject({ + runtimeApiKeySatisfied: false, + savedApiKeySatisfied: false, + }); + }); + + it('requires the AI Gateway id in addition to the token and account', () => { + const status = buildSetupModelStatus({ + runtimeEnv: { + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'cf-gateway-token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + }, + }); + + expect( + status.providers.find( + (provider) => provider.id === 'cloudflare-ai-gateway', + )?.runtimeApiKeySatisfied, + ).toBe(false); + }); +}); diff --git a/packages/types/src/model-provider-config.ts b/packages/types/src/model-provider-config.ts index 2a5c27fa6..7f879ee54 100644 --- a/packages/types/src/model-provider-config.ts +++ b/packages/types/src/model-provider-config.ts @@ -425,6 +425,85 @@ export const SETUP_MODEL_PROVIDER_CATALOG = [ 'minimax-m3': 'togetherai/MiniMaxAI/MiniMax-M3', }), }, + { + // Provider id matches the models.dev `cloudflare-ai-gateway` provider so + // catalog suggestion derivation and gateway pricing lookup resolve + // against that multi-vendor catalog. + id: 'cloudflare-ai-gateway', + label: 'Cloudflare AI Gateway', + envVarName: 'CLOUDFLARE_AI_GATEWAY_API_TOKEN', + envVarLabel: 'API token', + authKind: 'api-key', + credentialHelp: { + text: 'Create a Cloudflare API token with AI Gateway access. Use the account ID and gateway ID from the Cloudflare dashboard. Connecting this provider does not connect Workers AI.', + href: 'https://developers.cloudflare.com/ai-gateway/get-started/', + linkLabel: 'Open Cloudflare AI Gateway docs', + }, + additionalEnvFields: [ + { + envVarName: 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID', + label: 'Account ID', + secret: false, + required: true, + placeholder: 'your-account-id', + }, + { + envVarName: 'CLOUDFLARE_AI_GATEWAY_ID', + label: 'Gateway ID', + secret: false, + required: true, + placeholder: 'default', + }, + ], + defaultRoomoteModel: 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + suggestedTaskModels: mapRecommendedTaskModels({ + 'claude-fable-5': 'cloudflare-ai-gateway/anthropic/claude-fable-5', + 'claude-haiku-4-5': 'cloudflare-ai-gateway/anthropic/claude-haiku-4-5', + 'claude-opus-5': 'cloudflare-ai-gateway/anthropic/claude-opus-5', + 'claude-sonnet-5': 'cloudflare-ai-gateway/anthropic/claude-sonnet-5', + 'gpt-5-6-sol': 'cloudflare-ai-gateway/openai/gpt-5.6-sol', + 'gpt-5-6-terra': 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + 'gpt-5-6-luna': 'cloudflare-ai-gateway/openai/gpt-5.6-luna', + 'glm-5-2': 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2', + 'kimi-k3': 'cloudflare-ai-gateway/moonshotai/kimi-k3', + }), + recommendedRoleModels: { + helper: 'cloudflare-ai-gateway/openai/gpt-5.6-luna', + codeReview: 'cloudflare-ai-gateway/anthropic/claude-sonnet-5', + explore: 'cloudflare-ai-gateway/openai/gpt-5.6-luna', + planning: 'cloudflare-ai-gateway/anthropic/claude-opus-5', + }, + recommendedRoleReasoningEfforts: { codeReview: 'medium' }, + }, + { + // Provider id matches the models.dev `cloudflare-workers-ai` provider so + // catalog suggestion derivation and gateway pricing lookup resolve + // against Cloudflare-hosted `@cf/` models. + id: 'cloudflare-workers-ai', + label: 'Cloudflare Workers AI', + envVarName: 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + envVarLabel: 'API token', + authKind: 'api-key', + credentialHelp: { + text: 'Create a Cloudflare API token with Workers AI access. Use the account ID from the Cloudflare dashboard. A gateway ID is not required, and connecting this provider does not connect AI Gateway.', + href: 'https://developers.cloudflare.com/workers-ai/configuration/open-ai-compatibility/', + linkLabel: 'Open Cloudflare Workers AI docs', + }, + additionalEnvFields: [ + { + envVarName: 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', + label: 'Account ID', + secret: false, + required: true, + placeholder: 'your-account-id', + }, + ], + defaultRoomoteModel: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + suggestedTaskModels: mapRecommendedTaskModels({ + 'glm-5-2': 'cloudflare-workers-ai/@cf/zai-org/glm-5.2', + 'kimi-k2-7-code': 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + }), + }, { id: 'openai', label: 'OpenAI', diff --git a/packages/types/src/task-models.test.ts b/packages/types/src/task-models.test.ts index fe9cd1c83..36c32ea0e 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 855330b22..8375f3468 100644 --- a/packages/types/src/task-models.ts +++ b/packages/types/src/task-models.ts @@ -21,6 +21,8 @@ export const ENABLED_DIRECT_TASK_MODEL_PROVIDER_IDS = [ 'requesty', 'baseten', 'togetherai', + 'cloudflare-ai-gateway', + 'cloudflare-workers-ai', 'openai', 'azure', 'azure-cognitive-services', @@ -81,6 +83,8 @@ export const GATEWAY_TASK_MODEL_PROVIDER_IDS = [ 'requesty', 'baseten', 'togetherai', + 'cloudflare-ai-gateway', + 'cloudflare-workers-ai', ] as const; export const TASK_MODEL_INPUT_TYPES = [ From 05ce3837a072f51e579295df8df7df95e388fc2a Mon Sep 17 00:00:00 2001 From: Pride Musvaire Date: Thu, 13 Aug 2026 08:28:32 +0800 Subject: [PATCH 2/6] fix: route Cloudflare AI Gateway through OpenAI-compatible OpenCode SDK OpenCode would otherwise load models.dev's ai-gateway-provider and skip Roomote's /v1/chat/completions path. Token help now requires Workers AI, which the /ai/v1 REST surface actually checks. --- .../inference/cloudflare-ai-gateway.mdx | 15 +++--- apps/worker/src/run-task/agent-home.test.ts | 52 +++++++++++++++++++ apps/worker/src/run-task/agent-home.ts | 3 ++ .../src/__tests__/inference-gateway.test.ts | 2 + packages/types/src/inference-gateway.ts | 9 ++++ .../types/src/model-provider-config.test.ts | 6 +++ packages/types/src/model-provider-config.ts | 2 +- 7 files changed, 82 insertions(+), 7 deletions(-) diff --git a/apps/docs/providers/inference/cloudflare-ai-gateway.mdx b/apps/docs/providers/inference/cloudflare-ai-gateway.mdx index 6729ca3ae..d043c8eb1 100644 --- a/apps/docs/providers/inference/cloudflare-ai-gateway.mdx +++ b/apps/docs/providers/inference/cloudflare-ai-gateway.mdx @@ -14,10 +14,12 @@ 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 AI Gateway access. 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. +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. @@ -58,8 +60,9 @@ and invoice are authoritative. See [AI Gateway pricing](https://developers.cloud ## Common issues -- **The token is rejected.** Confirm it is a Cloudflare API token with AI - Gateway permission, not an unrelated Cloudflare global API key. +- **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 diff --git a/apps/worker/src/run-task/agent-home.test.ts b/apps/worker/src/run-task/agent-home.test.ts index 96490329e..a52ec2a13 100644 --- a/apps/worker/src/run-task/agent-home.test.ts +++ b/apps/worker/src/run-task/agent-home.test.ts @@ -353,6 +353,58 @@ 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('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 1f6021915..1f10f5cdb 100644 --- a/apps/worker/src/run-task/agent-home.ts +++ b/apps/worker/src/run-task/agent-home.ts @@ -1061,6 +1061,9 @@ function rebaseProviderOntoGateway( ...providerConfig, [openCodeProviderId]: { ...existingProvider, + ...(gatewayProvider.openCodeNpm + ? { npm: gatewayProvider.openCodeNpm } + : {}), options: { ...existingOptions, baseURL: buildInferenceGatewayOpenCodeBaseUrl( diff --git a/packages/types/src/__tests__/inference-gateway.test.ts b/packages/types/src/__tests__/inference-gateway.test.ts index d69e77a94..33c178241 100644 --- a/packages/types/src/__tests__/inference-gateway.test.ts +++ b/packages/types/src/__tests__/inference-gateway.test.ts @@ -334,6 +334,7 @@ describe('inference gateway key lookups', () => { headerName: 'cf-aig-gateway-id', }, ], + openCodeNpm: '@ai-sdk/openai-compatible', openCodeBaseUrlSuffix: '/v1', }); expect(provider?.allowedPaths).toEqual( @@ -364,6 +365,7 @@ describe('inference gateway key lookups', () => { 'https://api.cloudflare.com/client/v4/accounts/{resource}/ai', resource: { envVarName: 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID' }, authHeader: { name: 'authorization', scheme: 'bearer' }, + openCodeNpm: '@ai-sdk/openai-compatible', openCodeBaseUrlSuffix: '/v1', }); expect(provider?.requiredHeaders).toBeUndefined(); diff --git a/packages/types/src/inference-gateway.ts b/packages/types/src/inference-gateway.ts index 5fe4494f9..5c5d68fe0 100644 --- a/packages/types/src/inference-gateway.ts +++ b/packages/types/src/inference-gateway.ts @@ -192,6 +192,13 @@ export interface InferenceGatewayProvider { * like `/messages` or `/chat/completions` below it). */ openCodeBaseUrlSuffix: string; + /** + * OpenCode npm package to install when this provider is rebased onto the + * Roomote inference gateway. Required when models.dev's package is not an + * OpenAI-compatible factory (Cloudflare AI Gateway's `ai-gateway-provider` + * would otherwise ignore Roomote's `/v1/chat/completions` route). + */ + openCodeNpm?: string; } /** @@ -367,6 +374,7 @@ export const INFERENCE_GATEWAY_PROVIDERS: readonly InferenceGatewayProvider[] = ...OPENAI_COMPATIBLE_INFERENCE_PATHS, ...OPENAI_RESPONSES_INFERENCE_PATHS, ], + openCodeNpm: '@ai-sdk/openai-compatible', openCodeBaseUrlSuffix: '/v1', }, { @@ -378,6 +386,7 @@ export const INFERENCE_GATEWAY_PROVIDERS: readonly InferenceGatewayProvider[] = resource: { envVarName: 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID' }, authHeader: { name: 'authorization', scheme: 'bearer' }, allowedPaths: OPENAI_COMPATIBLE_INFERENCE_PATHS, + openCodeNpm: '@ai-sdk/openai-compatible', openCodeBaseUrlSuffix: '/v1', }, { diff --git a/packages/types/src/model-provider-config.test.ts b/packages/types/src/model-provider-config.test.ts index dbea72c0c..2cf7b81f9 100644 --- a/packages/types/src/model-provider-config.test.ts +++ b/packages/types/src/model-provider-config.test.ts @@ -1780,6 +1780,12 @@ describe('Cloudflare inference providers', () => { 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', diff --git a/packages/types/src/model-provider-config.ts b/packages/types/src/model-provider-config.ts index 7f879ee54..d13366c08 100644 --- a/packages/types/src/model-provider-config.ts +++ b/packages/types/src/model-provider-config.ts @@ -435,7 +435,7 @@ export const SETUP_MODEL_PROVIDER_CATALOG = [ envVarLabel: 'API token', authKind: 'api-key', credentialHelp: { - text: 'Create a Cloudflare API token with AI Gateway access. Use the account ID and gateway ID from the Cloudflare dashboard. Connecting this provider does not connect Workers AI.', + 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', }, From f2d8b1570322ad717b5009681370bcf8f7824298 Mon Sep 17 00:00:00 2001 From: Pride Musvaire Date: Thu, 13 Aug 2026 09:22:04 +0800 Subject: [PATCH 3/6] fix: teach control-plane OpenCode Roomote Cloudflare env names Helper inference now uses namespaced tokens, account ids, and gateway ids instead of models.dev CLOUDFLARE_ACCOUNT_ID. Also rewrite workers-ai/@cf slugs for /ai/v1 and accept underscore gateway ids. --- .../__tests__/inference-gateway.test.ts | 82 ++++++++++ apps/api/src/handlers/inference/index.ts | 11 +- apps/api/src/handlers/inference/registry.ts | 5 +- ecosystem.config.js | 5 + .../server/__tests__/opencode-runtime.test.ts | 53 +++++++ .../src/server/opencode-runtime.ts | 24 ++- .../src/__tests__/inference-gateway.test.ts | 33 ++++ .../opencode-provider-config.test.ts | 104 ++++++++++++- packages/types/src/inference-gateway.ts | 78 ++++++++++ .../types/src/opencode-provider-config.ts | 144 ++++++++++++++++++ 10 files changed, 529 insertions(+), 10 deletions(-) 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 0a618a99a..a9beb817b 100644 --- a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts +++ b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts @@ -1395,6 +1395,88 @@ describe('inference gateway', () => { ); }); + 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', diff --git a/apps/api/src/handlers/inference/index.ts b/apps/api/src/handlers/inference/index.ts index 087ceb90d..9b2a6e314 100644 --- a/apps/api/src/handlers/inference/index.ts +++ b/apps/api/src/handlers/inference/index.ts @@ -1,6 +1,9 @@ import { Hono } from 'hono'; -import { formatSingleLineLog } from '@roomote/types'; +import { + formatSingleLineLog, + rewriteCloudflareAiGatewayRequestBody, +} from '@roomote/types'; import { db, eq, taskRuns } from '@roomote/db/server'; import { recordLlmUsage } from '@roomote/sdk/server'; @@ -381,6 +384,12 @@ inference.on(['POST', 'GET'], '/:provider/*', async (c) => { } } + if (providerId === 'cloudflare-ai-gateway' && method === 'POST') { + const bodyText = await c.req.text(); + requestBody = rewriteCloudflareAiGatewayRequestBody(bodyText); + useDuplexHalf = false; + } + try { const upstreamResponse = await fetchWithLongLivedStreamDispatcher( upstreamUrl, diff --git a/apps/api/src/handlers/inference/registry.ts b/apps/api/src/handlers/inference/registry.ts index c3bbee679..e417640e1 100644 --- a/apps/api/src/handlers/inference/registry.ts +++ b/apps/api/src/handlers/inference/registry.ts @@ -1,6 +1,7 @@ import { CHATGPT_ACCOUNT_ID_HEADER, getInferenceGatewayProvider, + INFERENCE_GATEWAY_IDENTITY_PATTERN, INFERENCE_GATEWAY_RESOURCE_PATTERN, INFERENCE_GATEWAY_REGION_PATTERN, type InferenceGatewayProvider, @@ -118,9 +119,9 @@ async function resolveRequiredForwardHeaders( ); } - if (!INFERENCE_GATEWAY_RESOURCE_PATTERN.test(value)) { + if (!INFERENCE_GATEWAY_IDENTITY_PATTERN.test(value)) { throw new Error( - `${spec.envVarName} must be a valid resource name for ${provider.name}. Received "${value}".`, + `${spec.envVarName} must be a valid identity value for ${provider.name}. Received "${value}".`, ); } diff --git a/ecosystem.config.js b/ecosystem.config.js index 190ae7c9b..5b07b5013 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -16,6 +16,11 @@ const DEFAULT_OPENCODE_PROVIDER_ENV_KEYS = [ 'OPENCODE_API_KEY', 'BASETEN_API_KEY', 'TOGETHER_API_KEY', + 'CLOUDFLARE_AI_GATEWAY_API_TOKEN', + 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID', + 'CLOUDFLARE_AI_GATEWAY_ID', + 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', 'GEMINI_API_KEY', 'GOOGLE_GENERATIVE_AI_API_KEY', 'AWS_BEARER_TOKEN_BEDROCK', diff --git a/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts b/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts index 0f3cd542d..6cacc68f1 100644 --- a/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts +++ b/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts @@ -62,6 +62,59 @@ describe('buildOpenCodeCliEnv', () => { }); }); + it('materializes Cloudflare AI Gateway with Roomote env names for helper inference', () => { + const env = buildOpenCodeCliEnv({ + R_MODEL: 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + R_SMALL_MODEL: 'cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2', + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'my_gateway', + }); + + expect(JSON.parse(env.OPENCODE_CONFIG_CONTENT ?? '{}')).toMatchObject({ + model: 'cloudflare-ai-gateway/openai/gpt-5.6-terra', + small_model: 'cloudflare-ai-gateway/@cf/zai-org/glm-5.2', + provider: { + 'cloudflare-ai-gateway': { + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: + 'https://api.cloudflare.com/client/v4/accounts/a1b2c3d4e5f6789012345678abcdef90/ai/v1', + apiKey: '{env:CLOUDFLARE_AI_GATEWAY_API_TOKEN}', + headers: { 'cf-aig-gateway-id': 'my_gateway' }, + }, + }, + }, + }); + }); + + it('materializes Cloudflare Workers AI without a gateway header', () => { + const env = buildOpenCodeCliEnv({ + R_MODEL: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + CLOUDFLARE_WORKERS_AI_API_TOKEN: 'token', + CLOUDFLARE_WORKERS_AI_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + }); + + expect(JSON.parse(env.OPENCODE_CONFIG_CONTENT ?? '{}')).toMatchObject({ + model: 'cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code', + provider: { + 'cloudflare-workers-ai': { + npm: '@ai-sdk/openai-compatible', + options: { + baseURL: + 'https://api.cloudflare.com/client/v4/accounts/a1b2c3d4e5f6789012345678abcdef90/ai/v1', + apiKey: '{env:CLOUDFLARE_WORKERS_AI_API_TOKEN}', + }, + }, + }, + }); + expect( + JSON.parse(env.OPENCODE_CONFIG_CONTENT ?? '{}').provider[ + 'cloudflare-workers-ai' + ].options.headers, + ).toBeUndefined(); + }); + it('materializes LiteLLM provider config for restricted helper inference', () => { const env = buildOpenCodeCliEnv({ R_MODEL: 'litellm/qwen3.6:35b-unsloth', diff --git a/packages/cloud-agents/src/server/opencode-runtime.ts b/packages/cloud-agents/src/server/opencode-runtime.ts index cbc60ff04..8d0b78ba0 100644 --- a/packages/cloud-agents/src/server/opencode-runtime.ts +++ b/packages/cloud-agents/src/server/opencode-runtime.ts @@ -7,7 +7,9 @@ import { CHATGPT_FAST_MODE_ENV_VAR_NAME, DISABLED_MODEL_PROVIDER_ENV_VAR_NAMES, isTaskModelIdDisabled, + mergeCloudflareOpenCodeProviderConfig, mergeOpenAiCompatibleProviderConfig, + rewriteCloudflareOpenCodeModelId, mergeOpenCodeModelReasoningOptions, mergeOpenCodeChatGptFastModeOptions, mergeOpenRouterVariantAliasModels, @@ -46,16 +48,22 @@ function buildModelBackedOpenCodeConfigContent( // aliases below, because OpenCode rejects model IDs its catalog does not // contain. const variantAliases = new Map(); - const model = collectOpenRouterVariantModelAlias(variantAliases, rawModel); + const model = rewriteCloudflareOpenCodeModelId( + collectOpenRouterVariantModelAlias(variantAliases, rawModel), + ); const rawSmallModel = env.R_SMALL_MODEL?.trim(); const smallModel = rawSmallModel && !isTaskModelIdDisabled(rawSmallModel) - ? collectOpenRouterVariantModelAlias(variantAliases, rawSmallModel) + ? rewriteCloudflareOpenCodeModelId( + collectOpenRouterVariantModelAlias(variantAliases, rawSmallModel), + ) : undefined; const rawVisionModel = env.R_VISION_MODEL?.trim(); const visionModel = rawVisionModel && !isTaskModelIdDisabled(rawVisionModel) - ? collectOpenRouterVariantModelAlias(variantAliases, rawVisionModel) + ? rewriteCloudflareOpenCodeModelId( + collectOpenRouterVariantModelAlias(variantAliases, rawVisionModel), + ) : undefined; const modelReasoningEffort = normalizeOptionalReasoningEffort( env.R_MODEL_REASONING_EFFORT?.trim(), @@ -109,11 +117,15 @@ function buildModelBackedOpenCodeConfigContent( visionModel, ]) : providerReasoningConfig; - const providerConfig = mergeOpenAiCompatibleProviderConfig( - mergeOpenRouterVariantAliasModels(providerModelConfig, variantAliases), + const providerConfig = mergeCloudflareOpenCodeProviderConfig( + mergeOpenAiCompatibleProviderConfig( + mergeOpenRouterVariantAliasModels(providerModelConfig, variantAliases), + env, + [model, smallModel, visionModel], + visionModel, + ), env, [model, smallModel, visionModel], - visionModel, ); return JSON.stringify({ diff --git a/packages/types/src/__tests__/inference-gateway.test.ts b/packages/types/src/__tests__/inference-gateway.test.ts index 33c178241..31b710b14 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'; @@ -383,4 +386,34 @@ describe('inference gateway key lookups', () => { ), ).toBe('https://api.example.com/api/inference/cloudflare-workers-ai/v1'); }); + + it('accepts underscore gateway ids and rejects header-unsafe values', () => { + expect(INFERENCE_GATEWAY_IDENTITY_PATTERN.test('default')).toBe(true); + expect(INFERENCE_GATEWAY_IDENTITY_PATTERN.test('my_gateway')).toBe(true); + expect(INFERENCE_GATEWAY_IDENTITY_PATTERN.test('my-gateway')).toBe(true); + expect(INFERENCE_GATEWAY_IDENTITY_PATTERN.test('my gateway')).toBe(false); + expect(INFERENCE_GATEWAY_IDENTITY_PATTERN.test('gw\nid')).toBe(false); + }); + + it('strips the models.dev workers-ai namespace before /ai/v1', () => { + expect( + toCloudflareAiGatewayUpstreamModelId('workers-ai/@cf/zai-org/glm-5.2'), + ).toBe('@cf/zai-org/glm-5.2'); + expect(toCloudflareAiGatewayUpstreamModelId('openai/gpt-5.6-terra')).toBe( + 'openai/gpt-5.6-terra', + ); + expect( + rewriteCloudflareAiGatewayRequestBody( + JSON.stringify({ + model: 'workers-ai/@cf/zai-org/glm-5.2', + messages: [{ role: 'user', content: 'hi' }], + }), + ), + ).toBe( + JSON.stringify({ + model: '@cf/zai-org/glm-5.2', + messages: [{ role: 'user', content: 'hi' }], + }), + ); + }); }); diff --git a/packages/types/src/__tests__/opencode-provider-config.test.ts b/packages/types/src/__tests__/opencode-provider-config.test.ts index bc08f8336..223a700ec 100644 --- a/packages/types/src/__tests__/opencode-provider-config.test.ts +++ b/packages/types/src/__tests__/opencode-provider-config.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { mergeOpenAiCompatibleProviderConfig } from '../opencode-provider-config'; +import { + mergeCloudflareOpenCodeProviderConfig, + mergeOpenAiCompatibleProviderConfig, +} from '../opencode-provider-config'; describe('mergeOpenAiCompatibleProviderConfig', () => { it('materializes LiteLLM provider metadata for selected models', () => { @@ -88,3 +91,102 @@ describe('mergeOpenAiCompatibleProviderConfig', () => { }); }); }); + +describe('mergeCloudflareOpenCodeProviderConfig', () => { + it('materializes AI Gateway with Roomote env names and a gateway header', () => { + expect( + mergeCloudflareOpenCodeProviderConfig( + {}, + { + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'my_gateway', + }, + ['cloudflare-ai-gateway/openai/gpt-5.6-terra'], + ), + ).toEqual({ + 'cloudflare-ai-gateway': { + npm: '@ai-sdk/openai-compatible', + name: 'Cloudflare AI Gateway', + options: { + baseURL: + 'https://api.cloudflare.com/client/v4/accounts/a1b2c3d4e5f6789012345678abcdef90/ai/v1', + apiKey: '{env:CLOUDFLARE_AI_GATEWAY_API_TOKEN}', + headers: { 'cf-aig-gateway-id': 'my_gateway' }, + }, + models: { + 'openai/gpt-5.6-terra': { name: 'openai/gpt-5.6-terra' }, + }, + }, + }); + }); + + it('rewrites workers-ai/@cf models to @cf for the /ai/v1 surface', () => { + const merged = mergeCloudflareOpenCodeProviderConfig( + {}, + { + CLOUDFLARE_AI_GATEWAY_API_TOKEN: 'token', + CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + CLOUDFLARE_AI_GATEWAY_ID: 'default', + }, + ['cloudflare-ai-gateway/workers-ai/@cf/zai-org/glm-5.2'], + ); + + expect( + merged['cloudflare-ai-gateway'] as { models?: Record }, + ).toMatchObject({ + models: { + '@cf/zai-org/glm-5.2': { name: '@cf/zai-org/glm-5.2' }, + }, + }); + expect( + (merged['cloudflare-ai-gateway'] as { models?: Record }) + .models, + ).not.toHaveProperty('workers-ai/@cf/zai-org/glm-5.2'); + }); + + it('does not treat a complete AI Gateway config as Workers AI config', () => { + expect( + 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'], + ).toBeUndefined(); + }); + + it('materializes Workers AI without a gateway header', () => { + expect( + mergeCloudflareOpenCodeProviderConfig( + {}, + { + CLOUDFLARE_WORKERS_AI_API_TOKEN: 'token', + CLOUDFLARE_WORKERS_AI_ACCOUNT_ID: 'a1b2c3d4e5f6789012345678abcdef90', + }, + ['cloudflare-workers-ai/@cf/moonshotai/kimi-k2.7-code'], + ), + ).toEqual({ + 'cloudflare-workers-ai': { + npm: '@ai-sdk/openai-compatible', + name: 'Cloudflare Workers AI', + options: { + baseURL: + 'https://api.cloudflare.com/client/v4/accounts/a1b2c3d4e5f6789012345678abcdef90/ai/v1', + apiKey: '{env:CLOUDFLARE_WORKERS_AI_API_TOKEN}', + }, + models: { + '@cf/moonshotai/kimi-k2.7-code': { + name: '@cf/moonshotai/kimi-k2.7-code', + }, + }, + }, + }); + }); +}); diff --git a/packages/types/src/inference-gateway.ts b/packages/types/src/inference-gateway.ts index 5c5d68fe0..b5d47362a 100644 --- a/packages/types/src/inference-gateway.ts +++ b/packages/types/src/inference-gateway.ts @@ -46,6 +46,84 @@ export const INFERENCE_GATEWAY_REGION_PATTERN = export const INFERENCE_GATEWAY_RESOURCE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/iu; +/** + * Non-DNS identity values injected as headers (Cloudflare gateway ids). + * Allows underscores and up to 64 characters; rejects spaces and CR/LF. + */ +export const INFERENCE_GATEWAY_IDENTITY_PATTERN = + /^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62}[A-Za-z0-9])?$/u; + +const CLOUDFLARE_WORKERS_AI_CATALOG_PREFIX = 'workers-ai/'; + +/** + * models.dev stores hosted Workers AI models under a `workers-ai/` namespace + * on the AI Gateway provider. Cloudflare's `/ai/v1` surface expects the + * `@cf/...` id with that namespace removed. + */ +export function toCloudflareAiGatewayUpstreamModelId(modelId: string): string { + const trimmed = modelId.trim(); + + if ( + trimmed.startsWith(CLOUDFLARE_WORKERS_AI_CATALOG_PREFIX) && + trimmed + .slice(CLOUDFLARE_WORKERS_AI_CATALOG_PREFIX.length) + .startsWith('@cf/') + ) { + return trimmed.slice(CLOUDFLARE_WORKERS_AI_CATALOG_PREFIX.length); + } + + return trimmed; +} + +/** Rewrites a Roomote task model id for OpenCode / Cloudflare `/ai/v1`. */ +export function rewriteCloudflareOpenCodeModelId(modelId: string): string { + const prefix = 'cloudflare-ai-gateway/'; + + if (!modelId.startsWith(prefix)) { + return modelId; + } + + return `${prefix}${toCloudflareAiGatewayUpstreamModelId(modelId.slice(prefix.length))}`; +} + +/** + * Rewrites a JSON chat-completions body so Cloudflare `/ai/v1` receives + * `@cf/...` instead of models.dev's `workers-ai/@cf/...` catalog slug. + */ +export function rewriteCloudflareAiGatewayRequestBody( + bodyText: string, +): string { + if (!bodyText.trim()) { + return bodyText; + } + + let body: unknown; + + try { + body = JSON.parse(bodyText); + } catch { + return bodyText; + } + + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return bodyText; + } + + const record = body as { model?: unknown }; + + if (typeof record.model !== 'string') { + return bodyText; + } + + const rewritten = toCloudflareAiGatewayUpstreamModelId(record.model); + + if (rewritten === record.model) { + return bodyText; + } + + return JSON.stringify({ ...record, model: rewritten }); +} + /** Default AWS region for the Bedrock Mantle Anthropic-compatible endpoint. */ export const DEFAULT_BEDROCK_MANTLE_REGION = 'us-east-1'; diff --git a/packages/types/src/opencode-provider-config.ts b/packages/types/src/opencode-provider-config.ts index c10eaa887..2588e9465 100644 --- a/packages/types/src/opencode-provider-config.ts +++ b/packages/types/src/opencode-provider-config.ts @@ -1,3 +1,11 @@ +import { + getInferenceGatewayProvider, + INFERENCE_GATEWAY_IDENTITY_PATTERN, + INFERENCE_GATEWAY_RESOURCE_PATTERN, + toCloudflareAiGatewayUpstreamModelId, + type InferenceGatewayProvider, +} from './inference-gateway'; +import { getSetupModelProvider } from './model-provider-config'; import { buildOpenAiCompatibleProviderInstance, getOpenAiCompatibleProviderInstance, @@ -238,3 +246,139 @@ export function mergeOpenAiCompatibleProviderConfig( return merged; } + +const CLOUDFLARE_OPENCODE_PROVIDER_IDS = [ + 'cloudflare-ai-gateway', + 'cloudflare-workers-ai', +] as const; + +function readRequiredEnv( + runtimeEnv: RuntimeEnv, + envVarName: string | undefined, +): string | undefined { + return envVarName ? runtimeEnv[envVarName]?.trim() || undefined : undefined; +} + +/** + * Control-plane OpenCode has no Roomote inference gateway. Emit openai-compat + * providers against Cloudflare `/ai/v1` using Roomote's namespaced env vars, + * not models.dev's shared `CLOUDFLARE_ACCOUNT_ID`. + */ +export function mergeCloudflareOpenCodeProviderConfig( + providerConfig: Record, + runtimeEnv: RuntimeEnv, + modelIds: Array, +): Record { + let merged = providerConfig; + + for (const providerId of CLOUDFLARE_OPENCODE_PROVIDER_IDS) { + const gatewayProvider = getInferenceGatewayProvider(providerId); + const setupProvider = getSetupModelProvider(providerId); + + if (!gatewayProvider?.resource || !setupProvider.envVarName) { + continue; + } + + const prefix = `${providerId}/`; + const modelIdsForProvider = [ + ...new Set( + modelIds.flatMap((modelId) => { + const normalized = modelId?.trim(); + return normalized?.startsWith(prefix) + ? [ + providerId === 'cloudflare-ai-gateway' + ? toCloudflareAiGatewayUpstreamModelId( + normalized.slice(prefix.length), + ) + : normalized.slice(prefix.length), + ] + : []; + }), + ), + ]; + + if (modelIdsForProvider.length === 0) { + continue; + } + + const apiKey = readRequiredEnv(runtimeEnv, setupProvider.envVarName); + const accountId = readRequiredEnv( + runtimeEnv, + gatewayProvider.resource.envVarName, + ); + + if ( + !apiKey || + !accountId || + !INFERENCE_GATEWAY_RESOURCE_PATTERN.test(accountId) + ) { + continue; + } + + const existingProvider = asRecord(merged[providerId]); + const existingOptions = asRecord(existingProvider.options); + const existingModels = asRecord(existingProvider.models); + const options: Record = { + ...existingOptions, + baseURL: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`, + apiKey: `{env:${setupProvider.envVarName}}`, + }; + + if ( + !appendRequiredCloudflareHeaders(options, gatewayProvider, runtimeEnv) + ) { + continue; + } + + merged = { + ...merged, + [providerId]: { + ...existingProvider, + npm: gatewayProvider.openCodeNpm ?? '@ai-sdk/openai-compatible', + name: setupProvider.label, + options, + models: { + ...existingModels, + ...Object.fromEntries( + modelIdsForProvider.map((modelId) => [ + modelId, + { + name: modelId, + ...asRecord(existingModels[modelId]), + }, + ]), + ), + }, + }, + }; + } + + return merged; +} + +function appendRequiredCloudflareHeaders( + options: Record, + gatewayProvider: InferenceGatewayProvider, + runtimeEnv: RuntimeEnv, +): boolean { + if (!gatewayProvider.requiredHeaders?.length) { + return true; + } + + const headers = { + ...asRecord(options.headers), + }; + + for (const spec of gatewayProvider.requiredHeaders) { + const value = readRequiredEnv(runtimeEnv, spec.envVarName); + + if (!value || !INFERENCE_GATEWAY_IDENTITY_PATTERN.test(value)) { + return false; + } + + headers[spec.headerName] = value; + } + + options.headers = headers; + return true; +} From 557dfb0536752c36b3b0b9a8842f13b5fc1cbe35 Mon Sep 17 00:00:00 2001 From: Pride Musvaire Date: Thu, 13 Aug 2026 10:20:12 +0800 Subject: [PATCH 4/6] fix: bind Cloudflare OpenCode config in direct-mode tasks Direct tasks skipped the Roomote Cloudflare provider merge, so self-hosted runs used models.dev env names. Keep tokens on the control plane in SELF_HOSTING and allow Workers AI /v1/responses. --- SELF_HOSTING.md | 16 ++-- .../__tests__/inference-gateway.test.ts | 25 +++++ apps/worker/src/run-task/agent-home.test.ts | 92 +++++++++++++++++++ apps/worker/src/run-task/agent-home.ts | 50 ++++++---- .../src/__tests__/inference-gateway.test.ts | 6 +- packages/types/src/inference-gateway.ts | 5 +- .../types/src/opencode-provider-config.ts | 7 +- 7 files changed, 171 insertions(+), 30 deletions(-) diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index e49361ca7..9171f0f1d 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -396,8 +396,11 @@ model. When unset, exploration falls back to the task's active coding model: R_EXPLORE_MODEL=openrouter/openai/gpt-5.6-luna ``` -The provider is the first segment of the model id. Roomote forwards these -common provider keys into worker containers: +The provider is the first segment of the model id. Configure these common +provider keys on the Roomote control plane. When the inference gateway is +enabled, API tokens stay on the control plane and sandboxes authenticate with +a run token. Non-secret identity values such as account IDs, gateway IDs, and +regions remain available to the task runtime: - `OPENROUTER_API_KEY` - `AI_GATEWAY_API_KEY` (Vercel AI Gateway, `vercel/...` models) @@ -426,10 +429,11 @@ R_MODEL_ENV_KEYS=CUSTOM_PROVIDER_API_KEY CUSTOM_PROVIDER_API_KEY=... ``` -The checked-in Compose files forward the common provider keys above and the -sample `CUSTOM_PROVIDER_API_KEY`. If you use a different custom provider key -name in a Compose deployment, add that key to the service environment block or -provide it through your deployment secret mechanism. +The checked-in Compose files accept the common provider keys above and the +sample `CUSTOM_PROVIDER_API_KEY` on the control-plane services. If you use a +different custom provider key name in a Compose deployment, add that key to +the service environment block or provide it through your deployment secret +mechanism. ## Artifact Storage diff --git a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts index a9beb817b..3250f3cba 100644 --- a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts +++ b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts @@ -1371,6 +1371,31 @@ describe('inference gateway', () => { 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[]) => { diff --git a/apps/worker/src/run-task/agent-home.test.ts b/apps/worker/src/run-task/agent-home.test.ts index a52ec2a13..b21b2a9f1 100644 --- a/apps/worker/src/run-task/agent-home.test.ts +++ b/apps/worker/src/run-task/agent-home.test.ts @@ -405,6 +405,98 @@ describe('generateOpenCodeConfig provider support', () => { }); }); + 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 1f10f5cdb..228710b1f 100644 --- a/apps/worker/src/run-task/agent-home.ts +++ b/apps/worker/src/run-task/agent-home.ts @@ -29,7 +29,9 @@ import { type InferenceGatewayProvider, isConfiguredEnvValue, isTaskModelIdDisabled, + mergeCloudflareOpenCodeProviderConfig, mergeOpenAiCompatibleProviderConfig, + rewriteCloudflareOpenCodeModelId, mergeOpenCodeModelReasoningOptions, mergeOpenCodeChatGptFastModeOptions, mergeOpenRouterVariantAliasModels, @@ -852,6 +854,12 @@ function mergeAmazonBedrockProviderConfig( }; } +function toOpenCodeRuntimeModelId(modelId: string): string { + return rewriteCloudflareOpenCodeModelId( + toBedrockMantleRuntimeModelId(modelId), + ); +} + function toBedrockMantleRuntimeModelId(modelId: string): string { const mantlePrefix = `${BEDROCK_MANTLE_OPENCODE_PROVIDER_ID}/openai.`; const nativePrefix = `${AMAZON_BEDROCK_OPENCODE_PROVIDER_ID}/openai.`; @@ -1571,43 +1579,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; @@ -1783,19 +1791,23 @@ function resolveModelBackedOpenCodeConfig( ) : providerReasoningConfig; const providerConfig = mergeInferenceGatewayProviderConfig( - mergeOpenCodeGoProviderConfig( - mergeAzureCognitiveServicesProviderConfig( - mergeAmazonBedrockProviderConfig( - mergeBedrockMantleProviderConfig( - mergeBedrockMantleOpenAiProviderConfig( - mergeOpenAiCompatibleProviderConfig( - mergeOpenRouterVariantAliasModels( - providerModelConfig, - variantAliases, + mergeCloudflareOpenCodeProviderConfig( + mergeOpenCodeGoProviderConfig( + mergeAzureCognitiveServicesProviderConfig( + mergeAmazonBedrockProviderConfig( + mergeBedrockMantleProviderConfig( + mergeBedrockMantleOpenAiProviderConfig( + mergeOpenAiCompatibleProviderConfig( + mergeOpenRouterVariantAliasModels( + providerModelConfig, + variantAliases, + ), + runtimeEnv, + configuredModelIds, + visionModel ?? effectiveCodingModel, ), runtimeEnv, configuredModelIds, - visionModel ?? effectiveCodingModel, ), runtimeEnv, configuredModelIds, @@ -1803,11 +1815,11 @@ function resolveModelBackedOpenCodeConfig( runtimeEnv, configuredModelIds, ), - runtimeEnv, configuredModelIds, ), configuredModelIds, ), + runtimeEnv, configuredModelIds, ), runtimeEnv, @@ -1919,7 +1931,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/packages/types/src/__tests__/inference-gateway.test.ts b/packages/types/src/__tests__/inference-gateway.test.ts index 31b710b14..402e69b0b 100644 --- a/packages/types/src/__tests__/inference-gateway.test.ts +++ b/packages/types/src/__tests__/inference-gateway.test.ts @@ -373,7 +373,11 @@ describe('inference gateway key lookups', () => { }); expect(provider?.requiredHeaders).toBeUndefined(); expect(provider?.allowedPaths).toEqual( - expect.arrayContaining(['/v1/chat/completions', '/v1/embeddings']), + expect.arrayContaining([ + '/v1/chat/completions', + '/v1/embeddings', + '/v1/responses', + ]), ); expect( getInferenceGatewayProviderByEnvVarName('CLOUDFLARE_WORKERS_AI_API_TOKEN') diff --git a/packages/types/src/inference-gateway.ts b/packages/types/src/inference-gateway.ts index b5d47362a..480aae55f 100644 --- a/packages/types/src/inference-gateway.ts +++ b/packages/types/src/inference-gateway.ts @@ -463,7 +463,10 @@ export const INFERENCE_GATEWAY_PROVIDERS: readonly InferenceGatewayProvider[] = '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, + allowedPaths: [ + ...OPENAI_COMPATIBLE_INFERENCE_PATHS, + ...OPENAI_RESPONSES_INFERENCE_PATHS, + ], openCodeNpm: '@ai-sdk/openai-compatible', openCodeBaseUrlSuffix: '/v1', }, diff --git a/packages/types/src/opencode-provider-config.ts b/packages/types/src/opencode-provider-config.ts index 2588e9465..a2448e641 100644 --- a/packages/types/src/opencode-provider-config.ts +++ b/packages/types/src/opencode-provider-config.ts @@ -260,9 +260,10 @@ function readRequiredEnv( } /** - * Control-plane OpenCode has no Roomote inference gateway. Emit openai-compat - * providers against Cloudflare `/ai/v1` using Roomote's namespaced env vars, - * not models.dev's shared `CLOUDFLARE_ACCOUNT_ID`. + * 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, From b023ab22ac03cecbd38cd7a936e9614e8a941c28 Mon Sep 17 00:00:00 2001 From: Pride Musvaire Date: Thu, 13 Aug 2026 10:27:41 +0800 Subject: [PATCH 5/6] fix: include Cloudflare identity fields in provider setup tests The setup command already loads non-secret Cloudflare account and gateway ids. The web test still expected the pre-Cloudflare field list and failed CI. --- .../trpc/commands/task-models/index.test.ts | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) 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 4fb718a3c..d67e4a9d8 100644 --- a/apps/web/src/trpc/commands/task-models/index.test.ts +++ b/apps/web/src/trpc/commands/task-models/index.test.ts @@ -1,4 +1,8 @@ -import { normalizeTaskModelId } from '@roomote/types'; +import { + getSetupModelProviderAdditionalEnvFields, + normalizeTaskModelId, + SETUP_MODEL_PROVIDER_CATALOG, +} from '@roomote/types'; import type { UserAuthSuccess } from '@/types'; @@ -83,6 +87,12 @@ import { const PROVIDER_ENV_VAR_NAMES = [ 'OPENROUTER_API_KEY', + 'AI_GATEWAY_API_KEY', + 'CLOUDFLARE_AI_GATEWAY_API_TOKEN', + 'CLOUDFLARE_AI_GATEWAY_ACCOUNT_ID', + 'CLOUDFLARE_AI_GATEWAY_ID', + 'CLOUDFLARE_WORKERS_AI_API_TOKEN', + 'CLOUDFLARE_WORKERS_AI_ACCOUNT_ID', 'OPENAI_API_KEY', 'AZURE_API_KEY', 'AZURE_RESOURCE_NAME', @@ -1387,17 +1397,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', From 4a644b012912460362326d14b955fa704c05475d Mon Sep 17 00:00:00 2001 From: Pride Musvaire Date: Thu, 13 Aug 2026 10:34:34 +0800 Subject: [PATCH 6/6] fix: register rewritten Cloudflare @cf models without a sandbox token Gateway-mode tasks and control-plane prompts still sent models.dev workers-ai/@cf slugs. Register the rewritten @cf id even when the token is withheld, and rewrite before structured OpenCode SDK calls. --- apps/worker/src/run-task/agent-home.test.ts | 34 ++++++++++++ .../__tests__/non-task-provider-usage.test.ts | 35 ++++++++++++ .../src/server/non-task-provider-usage.ts | 2 + .../opencode-provider-config.test.ts | 53 ++++++++++++++----- .../types/src/opencode-provider-config.ts | 40 +++++++------- 5 files changed, 133 insertions(+), 31 deletions(-) diff --git a/apps/worker/src/run-task/agent-home.test.ts b/apps/worker/src/run-task/agent-home.test.ts index b21b2a9f1..83e71c347 100644 --- a/apps/worker/src/run-task/agent-home.test.ts +++ b/apps/worker/src/run-task/agent-home.test.ts @@ -405,6 +405,40 @@ describe('generateOpenCodeConfig provider support', () => { }); }); + 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(), 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 b1e6b0d7a..2b35de769 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 @@ -541,6 +541,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 an audio-capable configured model for native file prompts', async () => { process.env = { ...originalEnv, 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 6ef9946bc..083d49526 100644 --- a/packages/cloud-agents/src/server/non-task-provider-usage.ts +++ b/packages/cloud-agents/src/server/non-task-provider-usage.ts @@ -7,6 +7,7 @@ import { type PermissionRuleset, } from '@opencode-ai/sdk/v2/client'; import { resolveEffectiveModelRuntimeEnv } from '@roomote/db/server'; +import { rewriteCloudflareOpenCodeModelId } from '@roomote/types'; import type { z } from 'zod'; import zodToJsonSchema from 'zod-to-json-schema'; @@ -155,6 +156,7 @@ function splitOpenCodeModelId(model: string): { providerID: string; modelID: string; } { + model = rewriteCloudflareOpenCodeModelId(model); const separatorIndex = model.indexOf('/'); if (separatorIndex <= 0 || separatorIndex === model.length - 1) { diff --git a/packages/types/src/__tests__/opencode-provider-config.test.ts b/packages/types/src/__tests__/opencode-provider-config.test.ts index 223a700ec..16befe2f1 100644 --- a/packages/types/src/__tests__/opencode-provider-config.test.ts +++ b/packages/types/src/__tests__/opencode-provider-config.test.ts @@ -146,19 +146,48 @@ describe('mergeCloudflareOpenCodeProviderConfig', () => { }); 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( - 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'], + (merged['cloudflare-ai-gateway'] as { options?: Record }) + .options?.baseURL, + ).toBeUndefined(); + expect( + (merged['cloudflare-ai-gateway'] as { options?: Record }) + .options?.apiKey, ).toBeUndefined(); }); diff --git a/packages/types/src/opencode-provider-config.ts b/packages/types/src/opencode-provider-config.ts index a2448e641..2ab5bbfab 100644 --- a/packages/types/src/opencode-provider-config.ts +++ b/packages/types/src/opencode-provider-config.ts @@ -302,33 +302,35 @@ export function mergeCloudflareOpenCodeProviderConfig( continue; } - const apiKey = readRequiredEnv(runtimeEnv, setupProvider.envVarName); - const accountId = readRequiredEnv( - runtimeEnv, - gatewayProvider.resource.envVarName, - ); - - if ( - !apiKey || - !accountId || - !INFERENCE_GATEWAY_RESOURCE_PATTERN.test(accountId) - ) { - continue; - } - const existingProvider = asRecord(merged[providerId]); const existingOptions = asRecord(existingProvider.options); const existingModels = asRecord(existingProvider.models); const options: Record = { ...existingOptions, - baseURL: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`, - apiKey: `{env:${setupProvider.envVarName}}`, }; - + 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 ( - !appendRequiredCloudflareHeaders(options, gatewayProvider, runtimeEnv) + apiKey && + accountId && + INFERENCE_GATEWAY_RESOURCE_PATTERN.test(accountId) ) { - continue; + 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 = {