From 8abc8209fce73be42492a75f262b504ecbeeb4fa Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 17 Sep 2026 20:03:03 -0400 Subject: [PATCH 1/2] feat: route model inference through AI Gateway --- .dev.vars.example | 8 ++- .github/factory.yml | 10 ++-- README.md | 62 ++++++++++++-------- flue.config.ts | 10 ++-- package.json | 2 +- pnpm-lock.yaml | 6 +- src/ai-gateway.ts | 102 +++++++++++++++++++++++++++++++++ src/app.ts | 4 +- src/models.ts | 27 ++++----- tests/ai-gateway.test.ts | 120 +++++++++++++++++++++++++++++++++++++++ tests/config.test.ts | 51 ++++++++++------- wrangler.jsonc | 8 +-- 12 files changed, 327 insertions(+), 83 deletions(-) create mode 100644 src/ai-gateway.ts create mode 100644 tests/ai-gateway.test.ts diff --git a/.dev.vars.example b/.dev.vars.example index 8c59720..088c9c9 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -2,6 +2,8 @@ GITHUB_APP_ID="123456" GITHUB_APP_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----" GITHUB_WEBHOOK_SECRET="replace-me" -# Only needed when a repository configures an `anthropic/...` model. Workers AI -# models (the defaults) run through the `AI` binding and need no credentials. -# ANTHROPIC_API_KEY="sk-ant-..." +# Cross-account AI Gateway credentials. Keep all three as encrypted Worker +# secrets in production; these placeholders disclose no gateway values. +FACTORY_AI_GATEWAY_TOKEN="replace-me" +FACTORY_AI_GATEWAY_ACCOUNT_ID="replace-me" +FACTORY_AI_GATEWAY_ID="replace-me" diff --git a/.github/factory.yml b/.github/factory.yml index 0182207..2ea716f 100644 --- a/.github/factory.yml +++ b/.github/factory.yml @@ -15,18 +15,18 @@ adversary: trigger: label: ai-adversary blueTeam: - model: cloudflare/@cf/moonshotai/kimi-k2.7-code + model: cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.7-code purpleTeam: - model: cloudflare/@cf/moonshotai/kimi-k2.7-code + model: cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.7-code review: trigger: label: ai-review skill: .agents/skills/factory-review - model: cloudflare/@cf/moonshotai/kimi-k2.7-code + model: cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.7-code triage: enabled: true autoPrOnFix: true - model: cloudflare/@cf/moonshotai/kimi-k2.7-code - verificationModel: cloudflare/@cf/moonshotai/kimi-k2.6 + model: cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.7-code + verificationModel: cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.6 diff --git a/README.md b/README.md index 1756ab9..7d9a27c 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,10 @@ GitHub webhooks ─→ Hono ingress (signature verification) - **Workflows**: every side effect is a checkpointed, retried step. The triage workflow re-reads issue labels when it runs and routes through the FSM (`src/triage/fsm.ts`), so queued events always act on fresh state. -- **Agents**: Flue agents, defaulting to Workers AI (Kimi) via the `AI` - binding, which needs no credentials. Repositories can name a different model - per capability, including Anthropic models called directly (see - [Models](#models)). The reviewer gets read-only GitHub tools; the triage +- **Agents**: Flue agents, defaulting to Workers AI (Kimi) through a shared + **Cloudflare AI Gateway**. Repositories can name a different gateway-routed + model per capability, including Anthropic models (see [Models](#models)). + The reviewer gets read-only GitHub tools; the triage classifiers get no tools at all, only the conversation text. Only trusted workflow code writes to GitHub. @@ -179,16 +179,16 @@ adversary: label: ai-adversary blueTeam: # skill: .agents/skills/adversary-blue - # model: anthropic/claude-opus-4-6 + # model: cloudflare-ai-gateway/claude-opus-4-6 purpleTeam: # skill: .agents/skills/adversary-purple - # model: anthropic/claude-opus-4-6 + # model: cloudflare-ai-gateway/claude-opus-4-6 review: trigger: label: ai-review # skill: .agents/skills/astro-review # overrides the bundled default skill - # model: anthropic/claude-opus-4-6 # overrides the built-in reviewer model + # model: cloudflare-ai-gateway/claude-opus-4-6 # overrides the built-in reviewer model # severity: [critical, high, medium, low] # areas: [correctness, security, ...] @@ -197,8 +197,8 @@ triage: # autoPrOnFix: false # skill: .agents/skills/triage # overrides the bundled default skill # prWriterSkill: .agents/skills/pr-writer # adds repository-specific PR guidance - # model: anthropic/claude-opus-4-6 # reproduce/diagnose/fix pipeline - # verificationModel: anthropic/claude-haiku-4-5 # fix + retriage classifiers + # model: cloudflare-ai-gateway/claude-opus-4-6 # reproduce/diagnose/fix pipeline + # verificationModel: cloudflare-ai-gateway/claude-haiku-4-5 # classifiers # installCommand: pnpm install --no-frozen-lockfile # [] to install nothing # buildCommand: pnpm build # one command, a list, or a block scalar # previewRelease: @@ -296,19 +296,26 @@ the fix. ## Models -A model is named as `/`. Two providers are bundled: +A model is named as `/`. Factory bundles only the +`cloudflare-ai-gateway` provider, which can route to multiple upstreams while +ensuring every inference request passes through the shared gateway: -- `cloudflare/…` runs on **Workers AI** through the Worker's `AI` binding and - needs no credentials. Model ids carry their own slashes - (`cloudflare/@cf/moonshotai/kimi-k2.7-code`); only the first segment is the - provider. -- `anthropic/…` calls the **Anthropic API** directly — no AI Gateway in the - path — and requires the `ANTHROPIC_API_KEY` secret on the Worker. The key - belongs to the factory operator, not to target repositories; agent code never - sees it, because the Flue runtime resolves credentials from the environment. +- Workers AI model ids include a routing prefix and their own vendor segments, + for example + `cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.7-code`. +- Anthropic models use their normal model id, for example + `cloudflare-ai-gateway/claude-opus-4-6`. They use the gateway's native + Anthropic endpoint rather than calling Anthropic directly. -Three models are configurable, each defaulting to a Workers AI model so an -unconfigured repository keeps working with no API key: +The direct Workers AI and Anthropic providers are not bundled, and the Worker +has no `AI` binding, so repository configuration cannot bypass the gateway. +Gateway authentication is resolved inside the Flue runtime from encrypted +Worker secrets and is never available to target repositories or agents. Each +request disables prompt and response payload retention while leaving gateway +usage analytics available. + +Five model settings are configurable and default to gateway-routed Workers AI +models: | Setting | Used by | Default | | --- | --- | --- | @@ -323,9 +330,11 @@ conversation text and hold no tools, so they do not need a coding model. Providers are bundled at build time by the `providers` array in `flue.config.ts`, and `MODEL_PROVIDERS` in `src/models.ts` mirrors it. A model -naming any other provider is rejected when the configuration is parsed, rather -than failing at the first model call partway through an agent run — so adding a -provider means changing both places. +naming any provider other than `cloudflare-ai-gateway` is rejected when the +configuration is parsed rather than failing at the first model call partway +through an agent run. `src/ai-gateway.ts` overrides the bundled provider's auth +with Factory-scoped secrets and adds metadata for current Workers AI models +that have not reached Pi's gateway catalog yet. ## Preview releases @@ -385,8 +394,11 @@ Three deliberate design choices: - **Webhook URL**: `https:///channels/github/webhook`. - **Secrets** (`wrangler secret put` / `.dev.vars`): `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY` (PKCS#8 — convert with - `openssl pkcs8 -topk8 -nocrypt`), `GITHUB_WEBHOOK_SECRET`. Add - `ANTHROPIC_API_KEY` only if a repository configures an `anthropic/…` model. + `openssl pkcs8 -topk8 -nocrypt`), `GITHUB_WEBHOOK_SECRET`, + `FACTORY_AI_GATEWAY_TOKEN`, `FACTORY_AI_GATEWAY_ACCOUNT_ID`, and + `FACTORY_AI_GATEWAY_ID`. The gateway connection values deliberately use + Factory-scoped names so Wrangler cannot mistake the remote gateway account + or token for Factory's deployment credentials. Public and private repositories are both supported. Public repositories get an anonymous blobless clone (the triage sandbox holds no credentials at all); diff --git a/flue.config.ts b/flue.config.ts index 1ba9f58..a08435a 100644 --- a/flue.config.ts +++ b/flue.config.ts @@ -2,10 +2,8 @@ import { defineConfig } from '@flue/runtime/config'; export default defineConfig({ target: 'cloudflare', - // Only the listed providers are bundled, so this is the set a repository's - // `model` configuration can name. `cloudflare` runs on Workers AI through - // the `AI` binding with no credentials; `anthropic` calls the Anthropic API - // directly and needs the `ANTHROPIC_API_KEY` secret. Keep in sync with - // MODEL_PROVIDERS in src/models.ts. - providers: ['cloudflare', 'anthropic'], + // Factory deliberately exposes one provider. It can reach Workers AI, + // Anthropic, and other upstreams, but every request must pass through the + // shared AI Gateway. Keep in sync with MODEL_PROVIDERS in src/models.ts. + providers: ['cloudflare-ai-gateway'], }); diff --git a/package.json b/package.json index b1a74c8..ae095d4 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@biomejs/biome": "2.5.9", "@cloudflare/codemode": "^0.5.1", "@cloudflare/sandbox": "0.12.5", + "@earendil-works/pi-ai": "^0.83.0", "@flue/github": "^2.0.7", "@flue/runtime": "^2.0.7", "hono": "4.12.32", @@ -29,7 +30,6 @@ }, "devDependencies": { "@cloudflare/vite-plugin": "^1.51.1", - "@earendil-works/pi-ai": "^0.83.0", "@flue/cli": "^2.0.7", "@flue/vite": "^2.0.7", "@types/node": "^22.10.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6226863..0852265 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@cloudflare/sandbox': specifier: 0.12.5 version: 0.12.5 + '@earendil-works/pi-ai': + specifier: ^0.83.0 + version: 0.83.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(ws@8.21.3)(zod@4.4.3) '@flue/github': specifier: ^2.0.7 version: 2.0.7(@flue/runtime@2.0.7(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(typescript@7.0.2)(ws@8.21.3)(zod@4.4.3)) @@ -42,9 +45,6 @@ importers: '@cloudflare/vite-plugin': specifier: ^1.51.1 version: 1.52.1(vite@8.2.1(@types/node@22.20.1)(esbuild@0.28.2)(yaml@2.9.0))(wrangler@4.123.0(@cloudflare/workers-types@5.20260814.1)) - '@earendil-works/pi-ai': - specifier: ^0.83.0 - version: 0.83.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(ws@8.21.3)(zod@4.4.3) '@flue/cli': specifier: ^2.0.7 version: 2.0.7(@babel/core@8.0.1)(@babel/runtime@7.29.7)(@cloudflare/codemode@0.5.1(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(zod@4.4.3))(@cloudflare/workers-types@5.20260814.1)(@modelcontextprotocol/client@2.0.0)(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(@modelcontextprotocol/server@2.0.0)(@types/node@22.20.1)(esbuild@0.28.2)(hono@4.12.32)(just-bash@3.3.0)(react@19.2.8)(rolldown@1.2.4)(typescript@7.0.2)(ws@8.21.3)(yaml@2.9.0)(zod@4.4.3) diff --git a/src/ai-gateway.ts b/src/ai-gateway.ts new file mode 100644 index 0000000..06ac753 --- /dev/null +++ b/src/ai-gateway.ts @@ -0,0 +1,102 @@ +import type { Provider } from '@earendil-works/pi-ai'; +import { cloudflareAIGatewayProvider } from '@earendil-works/pi-ai/providers/cloudflare-ai-gateway'; +import { cloudflareWorkersAIProvider } from '@earendil-works/pi-ai/providers/cloudflare-workers-ai'; +import { CODE_MODEL_ID, WORKERS_AI_CODE_MODEL_ID } from './models.ts'; + +export const AI_GATEWAY_SECRETS = { + token: 'FACTORY_AI_GATEWAY_TOKEN', + accountId: 'FACTORY_AI_GATEWAY_ACCOUNT_ID', + gatewayId: 'FACTORY_AI_GATEWAY_ID', +} as const; + +/** + * Factory has access to a gateway in a different Cloudflare account. Keep its + * connection details in Factory-scoped Worker secrets: the generic + * CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_KEY names also control Wrangler and + * could accidentally redirect or authenticate a deployment. + */ +export function createFactoryAIGatewayProvider(): Provider { + const gateway = cloudflareAIGatewayProvider(); + const gatewayModels = gateway.getModels(); + + // Pi's gateway catalog currently trails Workers AI by one model. Reuse the + // Workers AI metadata and the gateway's /compat base URL until it catches up. + const models = gatewayModels.some((model) => model.id === CODE_MODEL_ID) + ? gatewayModels + : [ + ...gatewayModels, + createGatewayCodeModel( + gateway.id, + gatewayModels.find( + (model) => model.id === 'workers-ai/@cf/moonshotai/kimi-k2.6', + )?.baseUrl, + ), + ]; + + return { + ...gateway, + headers: { + ...gateway.headers, + // Factory handles private repositories and security reports. Preserve + // gateway usage analytics without retaining request or response bodies. + 'cf-aig-collect-log-payload': 'false', + }, + auth: { + apiKey: { + name: 'Factory AI Gateway token', + resolve: async ({ ctx }) => { + const [token, accountId, gatewayId] = await Promise.all([ + ctx.env(AI_GATEWAY_SECRETS.token), + ctx.env(AI_GATEWAY_SECRETS.accountId), + ctx.env(AI_GATEWAY_SECRETS.gatewayId), + ]); + if (!token || !accountId || !gatewayId) return undefined; + + return { + auth: { + headers: { + 'cf-aig-authorization': `Bearer ${token}`, + Authorization: null, + 'x-api-key': null, + }, + }, + env: { + CLOUDFLARE_ACCOUNT_ID: accountId, + CLOUDFLARE_GATEWAY_ID: gatewayId, + }, + source: 'Factory AI Gateway Worker secrets', + }; + }, + }, + }, + getModels: () => models, + }; +} + +function createGatewayCodeModel(provider: string, baseUrl: string | undefined) { + if (!baseUrl) { + throw new Error( + 'Cloudflare AI Gateway Workers AI base URL is unavailable.', + ); + } + + const source = cloudflareWorkersAIProvider() + .getModels() + .find((model) => model.id === WORKERS_AI_CODE_MODEL_ID); + if (!source) { + throw new Error('Workers AI Kimi K2.7 Code metadata is unavailable.'); + } + + return { + ...source, + id: CODE_MODEL_ID, + provider, + baseUrl, + compat: { + ...source.compat, + // The gateway's OpenAI-compatible endpoint forwards this Workers AI + // option even though Pi conservatively disables it for unknown models. + supportsReasoningEffort: true, + }, + }; +} diff --git a/src/app.ts b/src/app.ts index c9dcbe9..5d1e3aa 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,10 +1,12 @@ -import { instrument, observe } from '@flue/runtime'; +import { instrument, observe, setProvider } from '@flue/runtime'; import { createCloudflareTracing } from '@flue/runtime/cloudflare'; import { Hono } from 'hono'; +import { createFactoryAIGatewayProvider } from './ai-gateway.ts'; import { githubChannel } from './channels/github.ts'; import type { AppHonoEnv } from './env.ts'; import { createFlueEventLogger, type FlueEventLogger } from './flue-logging.ts'; +setProvider(createFactoryAIGatewayProvider()); instrument(createCloudflareTracing({ content: false })); const flueEventLoggers = new Map(); diff --git a/src/models.ts b/src/models.ts index 437981d..4b1b69e 100644 --- a/src/models.ts +++ b/src/models.ts @@ -2,13 +2,9 @@ * Model selection for every factory agent. * * A model specifier is `/`, resolved against the providers - * bundled by `flue.config.ts`: - * - * - `cloudflare/…` runs on Workers AI through the Worker's `AI` binding and - * needs no credentials. - * - `anthropic/…` calls the Anthropic API directly and needs the - * `ANTHROPIC_API_KEY` secret. Agents never see the key; the Flue runtime - * resolves it from the environment. + * bundled by `flue.config.ts`. Factory exposes only `cloudflare-ai-gateway/…`: + * Workers AI, Anthropic, and other upstream models all run through the shared + * gateway, whose credentials remain in Factory-scoped Worker secrets. * * The constants below are the defaults. A repository can override any of them * in `.github/factory.yml` (`adversary.blueTeam.model`, @@ -22,20 +18,25 @@ * bundled fails at the first model call, deep inside an agent, so * configuration is validated against this list up front instead. */ -export const MODEL_PROVIDERS = ['anthropic', 'cloudflare'] as const; +export const AI_GATEWAY_PROVIDER = 'cloudflare-ai-gateway'; +export const MODEL_PROVIDERS = [AI_GATEWAY_PROVIDER] as const; + +export const WORKERS_AI_CODE_MODEL_ID = '@cf/moonshotai/kimi-k2.7-code'; +export const CODE_MODEL_ID = `workers-ai/${WORKERS_AI_CODE_MODEL_ID}`; +export const VERIFICATION_MODEL_ID = 'workers-ai/@cf/moonshotai/kimi-k2.6'; /** Reviews and the triage pipeline: the strongest coding model. */ -export const CODE_MODEL = 'cloudflare/@cf/moonshotai/kimi-k2.7-code'; +export const CODE_MODEL = `${AI_GATEWAY_PROVIDER}/${CODE_MODEL_ID}`; /** Lightweight classification calls (fix verification, retriage decisions). */ -export const VERIFICATION_MODEL = 'cloudflare/@cf/moonshotai/kimi-k2.6'; +export const VERIFICATION_MODEL = `${AI_GATEWAY_PROVIDER}/${VERIFICATION_MODEL_ID}`; /** * The provider segment of a specifier, or `undefined` when it is malformed. * - * Only the first segment is the provider: Workers AI model ids carry their own - * slashes (`@cf/moonshotai/kimi-k2.6`), so the remainder is passed through - * untouched. + * Only the first segment is the provider: gateway model ids can carry their + * own routing and vendor segments (`workers-ai/@cf/…`), so the remainder is + * passed through untouched. */ export function modelProvider(specifier: string): string | undefined { const separator = specifier.indexOf('/'); diff --git a/tests/ai-gateway.test.ts b/tests/ai-gateway.test.ts new file mode 100644 index 0000000..f2b94b5 --- /dev/null +++ b/tests/ai-gateway.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + AI_GATEWAY_SECRETS, + createFactoryAIGatewayProvider, +} from '../src/ai-gateway.ts'; +import { + AI_GATEWAY_PROVIDER, + CODE_MODEL_ID, + VERIFICATION_MODEL_ID, +} from '../src/models.ts'; + +const secretValues: Record = { + [AI_GATEWAY_SECRETS.token]: 'gateway-token', + [AI_GATEWAY_SECRETS.accountId]: 'gateway-account', + [AI_GATEWAY_SECRETS.gatewayId]: 'gateway-id', +}; + +describe('Factory AI Gateway provider', () => { + it('exposes Workers AI and Anthropic only through the gateway provider', () => { + const provider = createFactoryAIGatewayProvider(); + const models = provider.getModels(); + + expect(provider.id).toBe(AI_GATEWAY_PROVIDER); + expect( + models.every((model) => model.provider === AI_GATEWAY_PROVIDER), + ).toBe(true); + expect(models.some((model) => model.id === CODE_MODEL_ID)).toBe(true); + expect(models.some((model) => model.id === VERIFICATION_MODEL_ID)).toBe( + true, + ); + expect(models.some((model) => model.id === 'claude-opus-4-6')).toBe(true); + expect(models.some((model) => model.id === 'claude-haiku-4-5')).toBe(true); + }); + + it('adds current Kimi metadata to the gateway compatibility endpoint', () => { + const model = createFactoryAIGatewayProvider() + .getModels() + .find((candidate) => candidate.id === CODE_MODEL_ID); + + expect(model).toMatchObject({ + provider: AI_GATEWAY_PROVIDER, + api: 'openai-completions', + baseUrl: + 'https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat', + compat: { + supportsReasoningEffort: true, + sendSessionAffinityHeaders: true, + }, + }); + }); + + it('uses the native Anthropic gateway endpoint for Claude', () => { + const model = createFactoryAIGatewayProvider() + .getModels() + .find((candidate) => candidate.id === 'claude-opus-4-6'); + + expect(model).toMatchObject({ + provider: AI_GATEWAY_PROVIDER, + api: 'anthropic-messages', + baseUrl: + 'https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic', + }); + }); + + it('maps Factory-scoped secrets into gateway auth without exposing them', async () => { + const provider = createFactoryAIGatewayProvider(); + const resolve = provider.auth.apiKey?.resolve; + expect(resolve).toBeDefined(); + if (!resolve) return; + + const env = vi.fn(async (name: string) => secretValues[name]); + const result = await resolve({ + ctx: { env, fileExists: async () => false }, + }); + + expect(env.mock.calls.map(([name]) => name)).toEqual([ + AI_GATEWAY_SECRETS.token, + AI_GATEWAY_SECRETS.accountId, + AI_GATEWAY_SECRETS.gatewayId, + ]); + expect(result).toEqual({ + auth: { + headers: { + 'cf-aig-authorization': 'Bearer gateway-token', + Authorization: null, + 'x-api-key': null, + }, + }, + env: { + CLOUDFLARE_ACCOUNT_ID: 'gateway-account', + CLOUDFLARE_GATEWAY_ID: 'gateway-id', + }, + source: 'Factory AI Gateway Worker secrets', + }); + }); + + it('is unavailable unless all three secrets are present', async () => { + const resolve = createFactoryAIGatewayProvider().auth.apiKey?.resolve; + expect(resolve).toBeDefined(); + if (!resolve) return; + + const result = await resolve({ + ctx: { + env: async (name) => + name === AI_GATEWAY_SECRETS.gatewayId + ? undefined + : secretValues[name], + fileExists: async () => false, + }, + }); + + expect(result).toBeUndefined(); + }); + + it('disables gateway payload retention', () => { + expect(createFactoryAIGatewayProvider().headers).toMatchObject({ + 'cf-aig-collect-log-payload': 'false', + }); + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts index deff3cc..ee5d108 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -76,21 +76,22 @@ adversary: label: ai-adversary blueTeam: skill: .agents/skills/adversary-blue - model: anthropic/claude-opus-4-6 + model: cloudflare-ai-gateway/claude-opus-4-6 purpleTeam: skill: .agents/skills/adversary-purple - model: cloudflare/@cf/moonshotai/kimi-k2.7-code + model: cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.7-code `), ).toMatchObject({ adversary: { trigger: { label: 'ai-adversary' }, blueTeam: { skill: '.agents/skills/adversary-blue', - model: 'anthropic/claude-opus-4-6', + model: 'cloudflare-ai-gateway/claude-opus-4-6', }, purpleTeam: { skill: '.agents/skills/adversary-purple', - model: 'cloudflare/@cf/moonshotai/kimi-k2.7-code', + model: + 'cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.7-code', }, }, }); @@ -361,51 +362,57 @@ triage: expect(() => parseFactoryConfig('version: 2')).toThrow(); }); - it('lets a repository choose a model per capability', () => { + it('lets a repository choose gateway-routed Anthropic models', () => { const config = parseFactoryConfig(` version: 1 review: trigger: label: ai-review - model: anthropic/claude-opus-4-6 + model: cloudflare-ai-gateway/claude-opus-4-6 triage: - model: anthropic/claude-opus-4-6 - verificationModel: anthropic/claude-haiku-4-5 + model: cloudflare-ai-gateway/claude-opus-4-6 + verificationModel: cloudflare-ai-gateway/claude-haiku-4-5 `); - expect(config.review?.model).toBe('anthropic/claude-opus-4-6'); - expect(config.triage.model).toBe('anthropic/claude-opus-4-6'); - expect(config.triage.verificationModel).toBe('anthropic/claude-haiku-4-5'); + expect(config.review?.model).toBe('cloudflare-ai-gateway/claude-opus-4-6'); + expect(config.triage.model).toBe('cloudflare-ai-gateway/claude-opus-4-6'); + expect(config.triage.verificationModel).toBe( + 'cloudflare-ai-gateway/claude-haiku-4-5', + ); }); - it('keeps Workers AI available alongside Anthropic', () => { + it('routes Workers AI through the same gateway provider', () => { const config = parseFactoryConfig(` version: 1 triage: - model: cloudflare/@cf/moonshotai/kimi-k2.7-code - verificationModel: anthropic/claude-haiku-4-5 + model: cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.7-code + verificationModel: cloudflare-ai-gateway/claude-haiku-4-5 `); - // Workers AI ids carry their own slashes; only the first segment is the - // provider, so the rest must survive intact. + // Gateway model ids carry their own routing and vendor segments; only the + // first segment is the provider, so the rest must survive intact. expect(config.triage.model).toBe( - 'cloudflare/@cf/moonshotai/kimi-k2.7-code', + 'cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.7-code', + ); + expect(config.triage.verificationModel).toBe( + 'cloudflare-ai-gateway/claude-haiku-4-5', ); - expect(config.triage.verificationModel).toBe('anthropic/claude-haiku-4-5'); }); it('falls back to the built-in models for capabilities that name none', () => { const config = parseFactoryConfig( - 'version: 1\ntriage:\n model: anthropic/claude-opus-4-6', + 'version: 1\ntriage:\n model: cloudflare-ai-gateway/claude-opus-4-6', ); - expect(config.triage.model).toBe('anthropic/claude-opus-4-6'); + expect(config.triage.model).toBe('cloudflare-ai-gateway/claude-opus-4-6'); expect(config.triage.verificationModel).toBe(VERIFICATION_MODEL); }); it.each([ 'openai/gpt-5', + 'cloudflare/@cf/moonshotai/kimi-k2.7-code', + 'anthropic/claude-opus-4-6', 'kimi-k2.6', '/claude-opus-4-6', - 'anthropic/', - 'Anthropic/claude-opus-4-6', + 'cloudflare-ai-gateway/', + 'Cloudflare-ai-gateway/claude-opus-4-6', ])( 'rejects a model that names an unbundled provider or is malformed: %s', (model) => { diff --git a/wrangler.jsonc b/wrangler.jsonc index bbcb1e6..64e47f0 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -9,12 +9,12 @@ "required": [ "GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY", - "GITHUB_WEBHOOK_SECRET" + "GITHUB_WEBHOOK_SECRET", + "FACTORY_AI_GATEWAY_TOKEN", + "FACTORY_AI_GATEWAY_ACCOUNT_ID", + "FACTORY_AI_GATEWAY_ID" ] }, - "ai": { - "binding": "AI" - }, "worker_loaders": [{ "binding": "LOADER" }], "r2_buckets": [ { From 1bc0d90cca4168559d855872fc425d63cdc91f35 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 17 Sep 2026 20:16:33 -0400 Subject: [PATCH 2/2] fix: normalize legacy model configuration --- README.md | 23 +++++++++++++---------- src/config.ts | 6 ++++-- src/models.ts | 39 ++++++++++++++++++++++++++++++++------- tests/config.test.ts | 19 +++++++++++++++++-- 4 files changed, 66 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 7d9a27c..3af38bf 100644 --- a/README.md +++ b/README.md @@ -309,10 +309,12 @@ ensuring every inference request passes through the shared gateway: The direct Workers AI and Anthropic providers are not bundled, and the Worker has no `AI` binding, so repository configuration cannot bypass the gateway. -Gateway authentication is resolved inside the Flue runtime from encrypted -Worker secrets and is never available to target repositories or agents. Each -request disables prompt and response payload retention while leaving gateway -usage analytics available. +Existing `anthropic/…` and `cloudflare/…` configuration values remain accepted +as migration aliases, but Factory rewrites them to their gateway equivalents +before an agent sees them. Gateway authentication is resolved inside the Flue +runtime from encrypted Worker secrets and is never available to target +repositories or agents. Each request disables prompt and response payload +retention while leaving gateway usage analytics available. Five model settings are configurable and default to gateway-routed Workers AI models: @@ -329,12 +331,13 @@ Defaults live in `src/models.ts`. The verification agents only classify conversation text and hold no tools, so they do not need a coding model. Providers are bundled at build time by the `providers` array in -`flue.config.ts`, and `MODEL_PROVIDERS` in `src/models.ts` mirrors it. A model -naming any provider other than `cloudflare-ai-gateway` is rejected when the -configuration is parsed rather than failing at the first model call partway -through an agent run. `src/ai-gateway.ts` overrides the bundled provider's auth -with Factory-scoped secrets and adds metadata for current Workers AI models -that have not reached Pi's gateway catalog yet. +`flue.config.ts`, and `MODEL_PROVIDERS` in `src/models.ts` mirrors it. New +configuration should name `cloudflare-ai-gateway`; the legacy `anthropic` and +`cloudflare` prefixes are syntax aliases only. Every other provider is rejected +when configuration is parsed rather than failing at the first model call +partway through an agent run. `src/ai-gateway.ts` overrides the bundled +provider's auth with Factory-scoped secrets and adds metadata for current +Workers AI models that have not reached Pi's gateway catalog yet. ## Preview releases diff --git a/src/config.ts b/src/config.ts index 2783175..c429f39 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,7 +17,8 @@ import { validateSkillDirectory } from './github/skill.ts'; import { CODE_MODEL, isSupportedModel, - MODEL_PROVIDERS, + MODEL_SPECIFIER_PROVIDERS, + normalizeModelSpecifier, VERIFICATION_MODEL, } from './models.ts'; import { @@ -108,8 +109,9 @@ const modelSchema = v.pipe( v.maxLength(200), v.check( isSupportedModel, - `A model must be "/", where provider is one of: ${MODEL_PROVIDERS.join(', ')}.`, + `A model must be "/", where provider is one of: ${MODEL_SPECIFIER_PROVIDERS.join(', ')}.`, ), + v.transform(normalizeModelSpecifier), ); const MAX_COMMANDS = 20; diff --git a/src/models.ts b/src/models.ts index 4b1b69e..bd57c90 100644 --- a/src/models.ts +++ b/src/models.ts @@ -4,7 +4,8 @@ * A model specifier is `/`, resolved against the providers * bundled by `flue.config.ts`. Factory exposes only `cloudflare-ai-gateway/…`: * Workers AI, Anthropic, and other upstream models all run through the shared - * gateway, whose credentials remain in Factory-scoped Worker secrets. + * gateway, whose credentials remain in Factory-scoped Worker secrets. Legacy + * direct-provider names are normalized to gateway model ids during parsing. * * The constants below are the defaults. A repository can override any of them * in `.github/factory.yml` (`adversary.blueTeam.model`, @@ -13,14 +14,20 @@ */ /** - * Providers a repository is allowed to name. This must stay in sync with the - * `providers` array in `flue.config.ts` — naming a provider that was not - * bundled fails at the first model call, deep inside an agent, so - * configuration is validated against this list up front instead. + * Providers available at runtime. This must stay in sync with the `providers` + * array in `flue.config.ts`; legacy config aliases are listed separately and + * normalized before a model call. */ export const AI_GATEWAY_PROVIDER = 'cloudflare-ai-gateway'; export const MODEL_PROVIDERS = [AI_GATEWAY_PROVIDER] as const; +/** Existing repository configs accepted as syntax aliases during migration. */ +export const LEGACY_MODEL_PROVIDERS = ['anthropic', 'cloudflare'] as const; +export const MODEL_SPECIFIER_PROVIDERS = [ + ...MODEL_PROVIDERS, + ...LEGACY_MODEL_PROVIDERS, +] as const; + export const WORKERS_AI_CODE_MODEL_ID = '@cf/moonshotai/kimi-k2.7-code'; export const CODE_MODEL_ID = `workers-ai/${WORKERS_AI_CODE_MODEL_ID}`; export const VERIFICATION_MODEL_ID = 'workers-ai/@cf/moonshotai/kimi-k2.6'; @@ -44,11 +51,29 @@ export function modelProvider(specifier: string): string | undefined { return specifier.slice(0, separator); } -/** Whether a specifier names a bundled provider and a non-empty model. */ +/** Whether a specifier names the gateway or a supported legacy alias. */ export function isSupportedModel(specifier: string): boolean { const provider = modelProvider(specifier); return ( provider !== undefined && - (MODEL_PROVIDERS as readonly string[]).includes(provider) + (MODEL_SPECIFIER_PROVIDERS as readonly string[]).includes(provider) ); } + +/** + * Convert direct-provider syntax from existing repository configs into the + * equivalent gateway model id. The direct providers are never registered. + */ +export function normalizeModelSpecifier(specifier: string): string { + const separator = specifier.indexOf('/'); + const provider = specifier.slice(0, separator); + const model = specifier.slice(separator + 1); + + if (provider === 'anthropic') { + return `${AI_GATEWAY_PROVIDER}/${model}`; + } + if (provider === 'cloudflare') { + return `${AI_GATEWAY_PROVIDER}/workers-ai/${model}`; + } + return specifier; +} diff --git a/tests/config.test.ts b/tests/config.test.ts index ee5d108..3aa76ad 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -397,6 +397,20 @@ triage: ); }); + it('normalizes legacy direct-provider names to gateway routes', () => { + const config = parseFactoryConfig(` +version: 1 +triage: + model: anthropic/claude-opus-4-6 + verificationModel: cloudflare/@cf/moonshotai/kimi-k2.6 +`); + + expect(config.triage.model).toBe('cloudflare-ai-gateway/claude-opus-4-6'); + expect(config.triage.verificationModel).toBe( + 'cloudflare-ai-gateway/workers-ai/@cf/moonshotai/kimi-k2.6', + ); + }); + it('falls back to the built-in models for capabilities that name none', () => { const config = parseFactoryConfig( 'version: 1\ntriage:\n model: cloudflare-ai-gateway/claude-opus-4-6', @@ -407,11 +421,12 @@ triage: it.each([ 'openai/gpt-5', - 'cloudflare/@cf/moonshotai/kimi-k2.7-code', - 'anthropic/claude-opus-4-6', 'kimi-k2.6', '/claude-opus-4-6', + 'anthropic/', + 'cloudflare/', 'cloudflare-ai-gateway/', + 'Anthropic/claude-opus-4-6', 'Cloudflare-ai-gateway/claude-opus-4-6', ])( 'rejects a model that names an unbundled provider or is malformed: %s',