| 1 | /** |
| 2 | * Which model a gateway request names, and where it goes: one of the |
| 3 | * workspace's own providers, or g1t's catalogue. |
| 4 | * |
| 5 | * - **The workspace's own providers come first.** Each connection lists the |
| 6 | * models it takes (`gatewayModels`: ids, or prefixes ending in `*`); the |
| 7 | * first connection, in the order they were made, whose list matches the |
| 8 | * model gets the request. A prefix ending in `/*` is a namespace the |
| 9 | * workspace chose, and is taken off before the request is sent. |
| 10 | * - **Then g1t's catalogue**, by the provider's own id (`claude-sonnet-5-5`, |
| 11 | * `@cf/openai/gpt-oss-120b`) or with its provider in front |
| 12 | * (`anthropic/claude-sonnet-5-5`, `workers-ai/@cf/openai/gpt-oss-120b`). |
| 13 | */ |
| 14 | |
| 15 | import type { GatewayModel, GatewayProvider } from "@g1t/contracts"; |
| 16 | |
| 17 | /** The kinds of request the gateway answers. */ |
| 18 | export type Kind = "chat" | "embeddings"; |
| 19 | |
| 20 | /** The catalogue's providers, and the API each speaks. */ |
| 21 | export const CATALOGUE_APIS: Record<string, "anthropic" | "openai"> = { |
| 22 | anthropic: "anthropic", |
| 23 | "workers-ai": "openai", |
| 24 | }; |
| 25 | |
| 26 | /** A catalogue model's id with its provider in front: `anthropic/claude-sonnet-5-5`. */ |
| 27 | export function catalogueId(model: Pick<GatewayModel, "provider" | "model">): string { |
| 28 | return `${model.provider}/${model.model}`; |
| 29 | } |
| 30 | |
| 31 | /** The model a request names without a catalogue provider in front, when it has one. */ |
| 32 | function unprefixed(requested: string): string | null { |
| 33 | for (const provider of Object.keys(CATALOGUE_APIS)) { |
| 34 | if (requested.startsWith(`${provider}/`)) return requested.slice(provider.length + 1); |
| 35 | } |
| 36 | return null; |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * Whether a connection's pattern takes a model, and the model to send if |
| 41 | * so: as named, or without the namespace a `/*` pattern stands for. |
| 42 | */ |
| 43 | export function matchPattern(pattern: string, model: string): string | null { |
| 44 | if (pattern === "*") return model; |
| 45 | if (pattern.endsWith("/*")) { |
| 46 | const prefix = pattern.slice(0, -1); |
| 47 | return model.startsWith(prefix) && model.length > prefix.length ? model.slice(prefix.length) : null; |
| 48 | } |
| 49 | if (pattern.endsWith("*")) return model.startsWith(pattern.slice(0, -1)) ? model : null; |
| 50 | return pattern === model ? model : null; |
| 51 | } |
| 52 | |
| 53 | /** Where a request goes. */ |
| 54 | export type Route = |
| 55 | | { to: "own"; provider: GatewayProvider; model: string } |
| 56 | | { to: "g1t"; entry: GatewayModel; api: "anthropic" | "openai"; model: string } |
| 57 | | { to: "none"; status: number; message: string }; |
| 58 | |
| 59 | /** The catalogue model a request names, if any. */ |
| 60 | export function findOffered(requested: string, offered: GatewayModel[]): GatewayModel | null { |
| 61 | const bare = unprefixed(requested); |
| 62 | if (bare != null) { |
| 63 | const provider = requested.slice(0, requested.length - bare.length - 1); |
| 64 | return offered.find((row) => row.provider === provider && row.model === bare) ?? null; |
| 65 | } |
| 66 | return offered.find((row) => row.model === requested) ?? null; |
| 67 | } |
| 68 | |
| 69 | /** The workspace's own provider a request goes to, if any, and the model it is sent as. */ |
| 70 | export function findOwn(requested: string, providers: GatewayProvider[]): { provider: GatewayProvider; model: string } | null { |
| 71 | const candidates = [requested]; |
| 72 | const bare = unprefixed(requested); |
| 73 | if (bare) candidates.push(bare); |
| 74 | for (const provider of providers) { |
| 75 | for (const candidate of candidates) { |
| 76 | for (const pattern of provider.patterns) { |
| 77 | const model = matchPattern(pattern, candidate); |
| 78 | if (model) return { provider, model }; |
| 79 | } |
| 80 | } |
| 81 | } |
| 82 | return null; |
| 83 | } |
| 84 | |
| 85 | /** Where a request for `requested` goes, or why it goes nowhere. */ |
| 86 | export function routeModel(requested: unknown, kind: Kind, providers: GatewayProvider[], offered: GatewayModel[]): Route { |
| 87 | if (typeof requested !== "string" || !requested.trim()) { |
| 88 | return { to: "none", status: 400, message: "Name a model: `model` is required." }; |
| 89 | } |
| 90 | const model = requested.trim(); |
| 91 | const own = findOwn(model, providers); |
| 92 | if (own) { |
| 93 | if (kind === "embeddings" && own.provider.api !== "openai") { |
| 94 | return { to: "none", status: 400, message: `${model} goes to ${own.provider.name}, which speaks Anthropic's API and has no embeddings.` }; |
| 95 | } |
| 96 | return { to: "own", ...own }; |
| 97 | } |
| 98 | const entry = findOffered(model, offered); |
| 99 | const api = entry ? CATALOGUE_APIS[entry.provider] : undefined; |
| 100 | if (entry && api && (entry.kind ?? "chat") === kind) return { to: "g1t", entry, api, model: entry.model }; |
| 101 | if (entry && api) { |
| 102 | const other = kind === "chat" ? "an embeddings model: send it to /openai/v1/embeddings" : "a chat model: send it to the chat or messages route"; |
| 103 | return { to: "none", status: 400, message: `${model} is ${other}.` }; |
| 104 | } |
| 105 | const names = offered |
| 106 | .filter((row) => (row.kind ?? "chat") === kind && CATALOGUE_APIS[row.provider]) |
| 107 | .map(catalogueId) |
| 108 | .filter((id, i, all) => all.indexOf(id) === i); |
| 109 | const own_ = providers.length |
| 110 | ? " or a model one of the workspace's own providers under Integrations takes" |
| 111 | : ", or connect the workspace's own provider under Integrations"; |
| 112 | return { |
| 113 | to: "none", |
| 114 | status: 404, |
| 115 | message: `${model} is not offered on the AI Gateway. Name one of ${names.join(", ")}${own_}. See https://docs.g1t.sh/guides/ai-gateway/#models`, |
| 116 | }; |
| 117 | } |
| 118 | |
| 119 | /** Dollars per million tokens, from millionths. */ |
| 120 | const dollars = (micros: number | undefined) => Math.round(Math.max(0, micros ?? 0)) / 1_000_000; |
| 121 | |
| 122 | /** One model, as `GET /openai/v1/models` lists it. */ |
| 123 | export type ListedModel = { |
| 124 | id: string; |
| 125 | object: "model"; |
| 126 | created: number; |
| 127 | owned_by: string; |
| 128 | name: string; |
| 129 | kind: Kind; |
| 130 | /** `g1t` when g1t's account serves it and the workspace is charged; `workspace` on its own provider. */ |
| 131 | billed_to: "g1t" | "workspace"; |
| 132 | connection: string | null; |
| 133 | /** g1t's price per million tokens, in dollars; null on the workspace's own provider. */ |
| 134 | pricing: { |
| 135 | currency: "usd"; |
| 136 | input: number; |
| 137 | output: number; |
| 138 | cache_read: number; |
| 139 | cache_write: number; |
| 140 | cache_write_1h: number; |
| 141 | long_prompt: { above_tokens: number; input: number; output: number; cache_read: number; cache_write: number; cache_write_1h: number } | null; |
| 142 | } | null; |
| 143 | }; |
| 144 | |
| 145 | /** |
| 146 | * The models a workspace can use, as OpenAI lists models: its own |
| 147 | * providers' first (by the ids they take), then g1t's catalogue with its |
| 148 | * prices, each model once, cheapest Claude first as the catalogue orders |
| 149 | * them. |
| 150 | */ |
| 151 | export function listModels(providers: GatewayProvider[], offered: GatewayModel[]): ListedModel[] { |
| 152 | const listed: ListedModel[] = []; |
| 153 | const seen = new Set<string>(); |
| 154 | const add = (model: ListedModel) => { |
| 155 | if (seen.has(model.id)) return; |
| 156 | seen.add(model.id); |
| 157 | listed.push(model); |
| 158 | }; |
| 159 | for (const provider of providers) { |
| 160 | for (const pattern of provider.patterns) { |
| 161 | if (!pattern.includes("*")) { |
| 162 | add(own(pattern, provider)); |
| 163 | continue; |
| 164 | } |
| 165 | const prefix = pattern.endsWith("/*") ? pattern.slice(0, -1) : ""; |
| 166 | for (const model of provider.models) { |
| 167 | const id = `${prefix}${model}`; |
| 168 | if (matchPattern(pattern, id)) add(own(id, provider)); |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | const names = new Set<string>(); |
| 173 | for (const row of offered) { |
| 174 | if (!CATALOGUE_APIS[row.provider]) continue; |
| 175 | // A dated id for a model already listed is the same model. |
| 176 | const key = `${row.provider}:${row.name}`; |
| 177 | if (names.has(key)) continue; |
| 178 | names.add(key); |
| 179 | // A catalogue model one of the workspace's providers takes goes there. |
| 180 | const mine = findOwn(catalogueId(row), providers); |
| 181 | if (mine) { |
| 182 | add({ ...own(catalogueId(row), mine.provider), name: row.name, kind: row.kind ?? "chat" }); |
| 183 | continue; |
| 184 | } |
| 185 | add({ |
| 186 | id: catalogueId(row), |
| 187 | object: "model", |
| 188 | created: 0, |
| 189 | owned_by: row.provider, |
| 190 | name: row.name, |
| 191 | kind: row.kind ?? "chat", |
| 192 | billed_to: "g1t", |
| 193 | connection: null, |
| 194 | pricing: { |
| 195 | currency: "usd", |
| 196 | input: dollars(row.inputMicros), |
| 197 | output: dollars(row.outputMicros), |
| 198 | cache_read: dollars(row.cacheReadMicros), |
| 199 | cache_write: dollars(row.cacheWriteMicros), |
| 200 | cache_write_1h: dollars(row.cacheWrite1hMicros || row.cacheWriteMicros), |
| 201 | long_prompt: row.threshold |
| 202 | ? { |
| 203 | above_tokens: row.threshold, |
| 204 | input: dollars(row.overInputMicros), |
| 205 | output: dollars(row.overOutputMicros), |
| 206 | cache_read: dollars(row.overCacheReadMicros), |
| 207 | cache_write: dollars(row.overCacheWriteMicros), |
| 208 | cache_write_1h: dollars(row.overCacheWrite1hMicros || row.overCacheWriteMicros), |
| 209 | } |
| 210 | : null, |
| 211 | }, |
| 212 | }); |
| 213 | } |
| 214 | return listed; |
| 215 | } |
| 216 | |
| 217 | function own(id: string, provider: GatewayProvider): ListedModel { |
| 218 | return { |
| 219 | id, |
| 220 | object: "model", |
| 221 | created: 0, |
| 222 | owned_by: provider.provider, |
| 223 | name: id, |
| 224 | kind: "chat", |
| 225 | billed_to: "workspace", |
| 226 | connection: provider.name, |
| 227 | pricing: null, |
| 228 | }; |
| 229 | } |