g1t/services/runner/src/model-env.ts

214 lines8,450 bytesCodeBlame
1/** The kinds of work a g1t agent does. Each is routed on its own. */
2export type AgentTask = "implement" | "review" | "update" | "plan";
3
4/**
5 * How capable, and how costly, a model is. g1t's hosted models come in
6 * two: `small` for work a smaller model does as well, `large` for the rest.
7 */
8export type Tier = "small" | "large";
9
10/** Where one kind of work goes: what people see, and what is sent. */
11export type ModelRoute = {
12 /** The model's public name, e.g. `Claude Sonnet 5.5`. */
13 modelName: string;
14 /** The identifier sent to the provider. */
15 model: string;
16};
17
18/**
19 * g1t's routing policy for the runs it pays the model for. Nobody
20 * assigning an agent picks a model; the work decides, here, and the
21 * operator changes it in one place (`AGENT_ROUTING` in wrangler.jsonc).
22 */
23export type AgentRouting = {
24 /** The model behind each tier. */
25 tiers: Record<Tier, ModelRoute>;
26 /**
27 * The tier each kind of work runs on. `change` decides by the change the
28 * work reads: small when it is small and touches nothing sensitive.
29 */
30 tasks: Record<AgentTask, Tier | "change">;
31 /** The largest change `change` sends to the small tier. */
32 smallChange: { files: number; lines: number };
33 /** Labels on the issue behind the work that send `change` to the large tier. */
34 largeLabels: string[];
35};
36
37/** What a change is, as far as routing cares. */
38export type ChangeSize = {
39 files: number;
40 /** Lines added and removed. */
41 lines: number;
42 /**
43 * What it touches that runs, configures or guards things: CI, secrets,
44 * infrastructure, ownership (work's confidence.rs `sensitive`).
45 */
46 sensitive: string[];
47};
48
49/** What g1t knows about one piece of work when it routes it. */
50export type RouteSignals = {
51 /** The change the work reads; null or absent when g1t does not know it. */
52 change?: ChangeSize | null;
53 /** Labels on the issue the work is for. */
54 labels?: string[];
55 /** The last attempt at the same work failed. */
56 retry?: boolean;
57};
58
59/** The routing g1t ships with, for whatever the configuration leaves out. */
60export const DEFAULT_ROUTING: AgentRouting = {
61 tiers: {
62 small: { modelName: "Claude Haiku 4.5", model: "claude-haiku-4-5-20251001" },
63 large: { modelName: "Claude Sonnet 5.5", model: "claude-sonnet-5-5" },
64 },
65 tasks: { implement: "large", review: "change", update: "small", plan: "small" },
66 smallChange: { files: 10, lines: 200 },
67 largeLabels: ["security"],
68};
69
70/**
71 * The routing in `AGENT_ROUTING`, with anything it leaves out taken from
72 * `DEFAULT_ROUTING`. An unset or unreadable value is the default.
73 */
74export function parseRouting(json: string | undefined): AgentRouting {
75 let given: Partial<AgentRouting> = {};
76 try {
77 given = json ? (JSON.parse(json) as Partial<AgentRouting>) : {};
78 } catch {
79 console.log("AGENT_ROUTING is not JSON; using the default routing");
80 }
81 return {
82 tiers: { ...DEFAULT_ROUTING.tiers, ...given.tiers },
83 tasks: { ...DEFAULT_ROUTING.tasks, ...given.tasks },
84 smallChange: { ...DEFAULT_ROUTING.smallChange, ...given.smallChange },
85 largeLabels: given.largeLabels ?? DEFAULT_ROUTING.largeLabels,
86 };
87}
88
89/**
90 * The tier one piece of work runs on: the cheapest that can do it.
91 * Planning and catching up are small; making a change is large; a review
92 * is small for a small change that touches nothing sensitive, and large
93 * for anything else, including a change g1t does not know the size of.
94 * A retry after a failed attempt is always large, so that work the small
95 * tier could not finish goes up rather than failing again the same way.
96 */
97export function chooseTier(task: AgentTask, signals: RouteSignals, routing: AgentRouting = DEFAULT_ROUTING): Tier {
98 if (signals.retry) return "large";
99 const rule = routing.tasks[task] ?? "large";
100 if (rule !== "change") return rule;
101 const change = signals.change;
102 if (!change || change.files === 0) return "large";
103 const large = new Set(routing.largeLabels.map((label) => label.toLowerCase()));
104 if ((signals.labels ?? []).some((label) => large.has(label.toLowerCase()))) return "large";
105 const small =
106 change.sensitive.length === 0 &&
107 change.files <= routing.smallChange.files &&
108 change.lines <= routing.smallChange.lines;
109 return small ? "small" : "large";
110}
111
112/** The settings that decide where model requests go. */
113export type ModelRouting = {
114 /**
115 * The provider's key. Not needed when the gateway holds it and requests
116 * authenticate to the gateway instead.
117 */
118 ANTHROPIC_API_KEY?: string;
119 /** A Cloudflare AI Gateway id; empty sends requests to the provider directly. */
120 AI_GATEWAY_ID: string;
121 CLOUDFLARE_ACCOUNT_ID: string;
122 /** Authenticates to the gateway, if it requires it. */
123 AI_GATEWAY_TOKEN?: string;
124};
125
126/**
127 * What a run is for, attached to each of its requests at the gateway.
128 * `session` is the run's id there: billing finds the run's requests by it
129 * and settles the run to what the gateway priced them at.
130 */
131export type RunTags = { repo: string; pull: number; session?: string };
132
133/**
134 * A session id for a run that goes straight to the gateway (no model
135 * proxy): `rs_` and 24 hex characters, which billing's log filter needs
136 * no escaping for.
137 */
138export function gatewaySession(): string {
139 const bytes = crypto.getRandomValues(new Uint8Array(12));
140 return `rs_${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
141}
142
143/** Whether there is a way to reach a model at all. */
144export function canReachModel(env: ModelRouting): boolean {
145 return Boolean(env.ANTHROPIC_API_KEY || (env.AI_GATEWAY_ID && env.AI_GATEWAY_TOKEN));
146}
147
148/**
149 * The model variables of a run on g1t's hosted models: the tier's model
150 * for the work, and the small tier's for the harness's own small tasks.
151 */
152export function tierVars(routing: AgentRouting, tier: Tier): Record<string, string> {
153 const route = routing.tiers[tier];
154 return {
155 ANTHROPIC_MODEL: route.model,
156 // Recorded at the top of the session, so anyone can see what ran.
157 AGENT_MODEL_NAME: route.modelName,
158 ANTHROPIC_DEFAULT_HAIKU_MODEL: routing.tiers.small.model,
159 ANTHROPIC_SMALL_FAST_MODEL: routing.tiers.small.model,
160 };
161}
162
163/** Where the sandbox sends model requests, and what it sends with them. */
164export function modelEnv(
165 env: ModelRouting,
166 routing: AgentRouting,
167 task: AgentTask,
168 tier: Tier,
169 tags: RunTags,
170): Record<string, string> {
171 const vars = tierVars(routing, tier);
172 if (env.ANTHROPIC_API_KEY) vars.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY;
173 if (!env.AI_GATEWAY_ID) return vars;
174
175 vars.ANTHROPIC_BASE_URL = `https://gateway.ai.cloudflare.com/v1/${env.CLOUDFLARE_ACCOUNT_ID}/${env.AI_GATEWAY_ID}/anthropic`;
176 // The gateway logs these with every request, so spend and failures can
177 // be read per kind of work, tier, repository and pull request; and by
178 // the run's session, which billing settles the run's charge by.
179 const headers = [`cf-aig-metadata: ${JSON.stringify({ task, tier, ...tags })}`];
180 if (env.AI_GATEWAY_TOKEN) {
181 vars.AI_GATEWAY_TOKEN = env.AI_GATEWAY_TOKEN;
182 headers.push(`cf-aig-authorization: Bearer ${env.AI_GATEWAY_TOKEN}`);
183 // With the provider's key stored in the gateway, the sandbox never
184 // holds it. The harness still wants the variable set.
185 vars.ANTHROPIC_API_KEY ??= env.AI_GATEWAY_TOKEN;
186 }
187 vars.ANTHROPIC_CUSTOM_HEADERS = headers.join("\n");
188 return vars;
189}
190
191/** Lines added and removed across a change's files. */
192export function changeSize(files: { additions: number; deletions: number }[], sensitive: string[]): ChangeSize {
193 return {
194 files: files.length,
195 lines: files.reduce((sum, file) => sum + file.additions + file.deletions, 0),
196 sensitive,
197 };
198}
199
200/** A past run of the same work, as the work service lists it. */
201export type PastAttempt = { status: string; halted?: string | null; title?: string | null };
202
203/**
204 * Whether the latest attempt at the same work failed: it failed, or g1t
205 * stopped it at a cap of its guardrails. A person stopping it is not a
206 * failure. `title` narrows it to the same plan, whose runs have no pull
207 * request to tell them apart.
208 */
209export function lastAttemptFailed(newestFirst: PastAttempt[], title?: string): boolean {
210 const last = newestFirst[0];
211 if (!last) return false;
212 if (title !== undefined && (last.title ?? "").trim() !== title.trim()) return false;
213 return last.status === "failed" || (last.status === "stopped" && Boolean(last.halted));
214}