Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
10 changes: 5 additions & 5 deletions .github/factory.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
71 changes: 43 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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, ...]

Expand All @@ -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:
Expand Down Expand Up @@ -296,19 +296,28 @@ the fix.

## Models

A model is named as `<provider>/<model>`. Two providers are bundled:

- `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.

Three models are configurable, each defaulting to a Workers AI model so an
unconfigured repository keeps working with no API key:
A model is named as `<provider>/<model>`. Factory bundles only the
`cloudflare-ai-gateway` provider, which can route to multiple upstreams while
ensuring every inference request passes through the shared gateway:

- 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.

The direct Workers AI and Anthropic providers are not bundled, and the Worker
has no `AI` binding, so repository configuration cannot bypass the gateway.
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:

| Setting | Used by | Default |
| --- | --- | --- |
Expand All @@ -322,10 +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 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.
`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

Expand Down Expand Up @@ -385,8 +397,11 @@ Three deliberate design choices:
- **Webhook URL**: `https://<worker>/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);
Expand Down
10 changes: 4 additions & 6 deletions flue.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

102 changes: 102 additions & 0 deletions src/ai-gateway.ts
Original file line number Diff line number Diff line change
@@ -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,
},
};
}
4 changes: 3 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
@@ -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<string, FlueEventLogger>();
Expand Down
6 changes: 4 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -108,8 +109,9 @@ const modelSchema = v.pipe(
v.maxLength(200),
v.check(
isSupportedModel,
`A model must be "<provider>/<model>", where provider is one of: ${MODEL_PROVIDERS.join(', ')}.`,
`A model must be "<provider>/<model>", where provider is one of: ${MODEL_SPECIFIER_PROVIDERS.join(', ')}.`,
),
v.transform(normalizeModelSpecifier),
);

const MAX_COMMANDS = 20;
Expand Down
Loading
Loading