Skip to content
176 linesCodeBlameRaw
1/**
2 * Which model a reply runs on. Pure, so it is tested on its own.
3 *
4 * Nobody picks a model: g1t routes each step to the tier it needs (chat
5 * replies start on `small`), and the agent's definition only limits that
6 * (docs.g1t.sh/guides/agents/, "Model routing"). The model behind each tier
7 * is the runner's routing policy (`AGENT_ROUTING`, with staff's defaults on
8 * top), read through the runner's own module so the two never disagree.
9 */
10import type { AgentEffort, AgentRouting as AgentLimits, EffortLevel, ModelTier } from "@g1t/contracts";
11
12import type { AgentRouting as Policy, TokenPrice } from "../../runner/src/model-env.ts";
13
14const ORDER: ModelTier[] = ["small", "large", "frontier"];
15
16/** Where a chat reply starts before the agent's limits apply. */
17export const REPLY_TIER: ModelTier = "small";
18
19export function isTier(value: unknown): value is ModelTier {
20 return value === "small" || value === "large" || value === "frontier";
21}
22
23/**
24 * `tier` held between the agent's floor and ceiling. When a definition
25 * has the floor above the ceiling (saved before validation said no, or
26 * edited by hand), the ceiling wins: it is the spending rail, and a rail
27 * is never crossed to honour a preference.
28 */
29export function clampTier(tier: ModelTier, floor: ModelTier | null, ceiling: ModelTier | null): ModelTier {
30 let at = ORDER.indexOf(tier);
31 if (floor && ORDER.indexOf(floor) > at) at = ORDER.indexOf(floor);
32 if (ceiling && ORDER.indexOf(ceiling) < at) at = ORDER.indexOf(ceiling);
33 return ORDER[at];
34}
35
36/** Whether a floor and a ceiling can both hold. */
37export function limitsAgree(floor: ModelTier | null, ceiling: ModelTier | null): boolean {
38 return !floor || !ceiling || ORDER.indexOf(floor) <= ORDER.indexOf(ceiling);
39}
40
41/** The word the agent's `providers` list uses for g1t's hosted models. */
42export const HOSTED = "g1t";
43/** The word the agent's `providers` list uses for the workspace's own providers, whichever they are. */
44export const OWN = "workspace";
45
46/**
47 * Where an agent's model calls may go: g1t's hosted models, the
48 * workspace's own provider, or both. `providers` empty means whatever the
49 * workspace allows; `g1t` is g1t's hosted models, `workspace` any of the
50 * workspace's own providers, and anything else one of them by integration
51 * id. `ownId` is the workspace's own model connection, if it has one.
52 */
53export function allowedProviders(limits: Pick<AgentLimits, "providers">, ownId: string | null): { hosted: boolean; own: boolean } {
54 const list = (limits.providers ?? []).map((p) => p.trim()).filter(Boolean);
55 if (!list.length) return { hosted: true, own: ownId != null };
56 return { hosted: list.includes(HOSTED), own: ownId != null && (list.includes(OWN) || list.includes(ownId)) };
57}
58
59/** The model a pinned `provider/model` names, or null. */
60export function pinnedModel(pinned: string | null | undefined): string | null {
61 if (!pinned) return null;
62 const at = pinned.indexOf("/");
63 const model = (at >= 0 ? pinned.slice(at + 1) : pinned).trim();
64 return model || null;
65}
66
67export type ReplyModel = {
68 tier: ModelTier;
69 /** Sent to the provider. */
70 model: string;
71 /** Shown to people and on the bill. */
72 modelName: string;
73 /** The provider's list price, when g1t knows it. */
74 price: TokenPrice | null;
75};
76
77/**
78 * The model a reply runs on: the reply tier (or `start`, where the work
79 * calls for another, or the tier the workspace chose for replies), held to
80 * the agent's limits, and the model the policy
81 * puts behind it. A pinned model, or a workspace route that names its own
82 * model, replaces the tier's model; it is not priced here, since it runs on
83 * the workspace's provider.
84 */
85export function replyModel(
86 policy: Pick<Policy, "tiers">,
87 limits: Pick<AgentLimits, "floor" | "ceiling" | "pinned">,
88 options: { chosen?: ModelTier | null; named?: string | null; start?: ModelTier } = {},
89): ReplyModel {
90 const start = options.chosen && isTier(options.chosen) ? options.chosen : (options.start ?? REPLY_TIER);
91 const tier = clampTier(start, limits.floor, limits.ceiling);
92 const named = options.named || pinnedModel(limits.pinned);
93 if (named) return { tier, model: named, modelName: named, price: null };
94 const route = policy.tiers[tier];
95 return { tier, model: route.model, modelName: route.modelName, price: route.price ?? null };
96}
97
98/**
99 * How hard an agent works (docs.g1t.sh/guides/agents/#effort): one setting
100 * that moves three things together, so nobody tunes them apart.
101 *
102 * | Setting | Starts on | Reasoning | A session's steps |
103 * | --- | --- | --- | --- |
104 * | low | small | low | 5 |
105 * | medium | where the work starts | medium | 8 |
106 * | high | large or above | high | 10 |
107 * | max | frontier | max | 12 |
108 *
109 * `auto` runs replies and sessions at medium, and a session at high once a
110 * person has had to steer it. The agent's floor and ceiling hold over all
111 * of it: the ceiling is the spending rail.
112 */
113export type EffortPlan = {
114 /** The level the work runs at, as recorded. */
115 level: EffortLevel;
116 /** The tier it starts on, before the floor and ceiling. */
117 start: ModelTier;
118 /** A session's step limit at this level. */
119 steps: number;
120};
121
122export const SESSION_STEPS: Record<EffortLevel, number> = { low: 5, medium: 8, high: 10, max: 12 };
123
124const LEVELS: EffortLevel[] = ["low", "medium", "high", "max"];
125
126export function isEffort(value: unknown): value is AgentEffort {
127 return value === "auto" || isLevel(value);
128}
129
130export function isLevel(value: unknown): value is EffortLevel {
131 return LEVELS.includes(value as EffortLevel);
132}
133
134/** The setting an agent's routing names; anything else, or none, is `auto`. */
135export function effortOf(routing: Pick<AgentLimits, "effort"> | null | undefined): AgentEffort {
136 const value = routing?.effort;
137 return isEffort(value) ? value : "auto";
138}
139
140/**
141 * What a setting means for one piece of work. `base` is where the work
142 * would start on its own (`REPLY_TIER` for a reply, `large` for a session
143 * step, higher for @g1t in a long thread); `raised` is a session someone
144 * had to steer, which Auto works harder on.
145 */
146export function effortPlan(setting: AgentEffort | null | undefined, base: ModelTier, options: { raised?: boolean } = {}): EffortPlan {
147 const level: EffortLevel = !setting || setting === "auto" ? (options.raised ? "high" : "medium") : setting;
148 let start = base;
149 if (level === "low") start = "small";
150 else if (level === "max") start = "frontier";
151 else if (level === "high" && ORDER.indexOf(base) < ORDER.indexOf("large")) start = "large";
152 return { level, start, steps: SESSION_STEPS[level] };
153}
154
155/**
156 * The reasoning effort sent with a request (`output_config.effort`): only
157 * on g1t's tiers whose model takes it (the catalogue's `effort`
158 * capability; a route from configuration says nothing, so it is sent, as
159 * the runner does), never to a model a workspace's own route names.
160 */
161export function reasoningEffort(level: EffortLevel, route: { capabilities?: string[] } | null, named: boolean): EffortLevel | null {
162 if (named || !route) return null;
163 if (route.capabilities && !route.capabilities.includes("effort")) return null;
164 return level;
165}
166
167/** The higher of two levels, for a session Auto raised partway. */
168export function higherEffort(a: EffortLevel | null, b: EffortLevel): EffortLevel {
169 return a && LEVELS.indexOf(a) > LEVELS.indexOf(b) ? a : b;
170}
171
172/** The level below, or null at the bottom. */
173export function lowerEffort(level: EffortLevel): EffortLevel | null {
174 const at = LEVELS.indexOf(level);
175 return at > 0 ? LEVELS[at - 1] : null;
176}