@jameslovespancakes/pi-plus 1.0.14 → 1.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -4
- package/package.json +5 -5
- package/src/core/accounts/oauth-pool.ts +4 -2
- package/src/core/accounts/registry.ts +7 -2
- package/src/core/anthropic/catalog.ts +139 -0
- package/src/core/anthropic/client-identity.ts +24 -5
- package/src/core/anthropic/models.ts +108 -26
- package/src/core/config.ts +1 -1
- package/src/core/gemini/LICENSE.md +21 -0
- package/src/core/gemini/client.ts +188 -0
- package/src/core/gemini/credentials.ts +56 -0
- package/src/core/gemini/models.ts +362 -0
- package/src/core/gemini/oauth.ts +240 -0
- package/src/core/gemini/request.ts +259 -0
- package/src/core/gemini/schema.ts +142 -0
- package/src/core/gemini/stream.ts +526 -0
- package/src/core/oauth/callback-server.ts +110 -0
- package/src/core/policy/policy.ts +3 -1
- package/src/domains/models/catalog-tool.ts +1 -1
- package/src/domains/subscriptions/accounts.ts +2 -2
- package/src/domains/subscriptions/footer.ts +1 -1
- package/src/domains/subscriptions/index.ts +3 -1
- package/src/domains/subscriptions/provider.ts +66 -7
- package/src/domains/subscriptions/providers/gemini.ts +111 -0
- package/src/domains/subscriptions/providers/oauth-pool.ts +35 -9
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gemini's `v1internal` control plane: project discovery, the account
|
|
5
|
+
* email, and the live model list.
|
|
6
|
+
*
|
|
7
|
+
* Plain `fetch`, no pi types, so everything here is unit-testable without a
|
|
8
|
+
* session. The wire details (endpoints, User-Agent, request bodies) follow
|
|
9
|
+
* the Antigravity CLI as tracked by `pi-antigravity`; the backend identifies
|
|
10
|
+
* its callers and refuses ones it does not recognise.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Daily carries rollouts first, so it leads; production is the last resort. */
|
|
14
|
+
export const GEMINI_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
|
15
|
+
export const GEMINI_ENDPOINTS: readonly string[] = [
|
|
16
|
+
GEMINI_ENDPOINT,
|
|
17
|
+
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
|
18
|
+
"https://cloudcode-pa.googleapis.com",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const GEMINI_USER_AGENT =
|
|
22
|
+
"antigravity/cli/1.1.23 (aidev_client; os_type=linux; arch=amd64; cl=974125021; auth_method=consumer)";
|
|
23
|
+
|
|
24
|
+
/** Metadata lookups must be quick; a stalled endpoint falls through to the next. */
|
|
25
|
+
const DISCOVERY_TIMEOUT_MS = 8_000;
|
|
26
|
+
|
|
27
|
+
export function geminiEnv(name: string): string | undefined {
|
|
28
|
+
const value = process.env[`PI_GEMINI_${name}`]?.trim();
|
|
29
|
+
return value || undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function geminiHeaders(token: string): Record<string, string> {
|
|
33
|
+
return {
|
|
34
|
+
Authorization: `Bearer ${token}`,
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
"User-Agent": GEMINI_USER_AGENT,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Endpoints for a request, in order. A base URL the user configured for the
|
|
42
|
+
* provider (pi's `models.json` override) is used alone; otherwise every
|
|
43
|
+
* Gemini endpoint is tried.
|
|
44
|
+
*/
|
|
45
|
+
export function endpointsFor(baseUrl?: string): readonly string[] {
|
|
46
|
+
const configured = baseUrl?.trim().replace(/\/+$/, "");
|
|
47
|
+
return configured && configured !== GEMINI_ENDPOINT ? [configured] : GEMINI_ENDPOINTS;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
51
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function timeoutSignal(signal?: AbortSignal): AbortSignal {
|
|
55
|
+
const timeout = AbortSignal.timeout(DISCOVERY_TIMEOUT_MS);
|
|
56
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** POSTs to one endpoint; undefined for any non-2xx or transport failure. */
|
|
60
|
+
async function postJson(endpoint: string, path: string, token: string, body: unknown, signal?: AbortSignal): Promise<unknown> {
|
|
61
|
+
try {
|
|
62
|
+
const response = await fetch(`${endpoint}/v1internal:${path}`, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: geminiHeaders(token),
|
|
65
|
+
body: JSON.stringify(body),
|
|
66
|
+
signal: timeoutSignal(signal),
|
|
67
|
+
});
|
|
68
|
+
if (!response.ok) return undefined;
|
|
69
|
+
return await response.json();
|
|
70
|
+
} catch (error) {
|
|
71
|
+
// A caller abort is a decision, not an endpoint failure to route around.
|
|
72
|
+
if (signal?.aborted) throw error;
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** UUID-shaped id derived from a seed, so the same account always maps to the same value. */
|
|
78
|
+
export function stableUuid(seed: string): string {
|
|
79
|
+
const bytes = createHash("sha1").update(seed).digest().subarray(0, 16);
|
|
80
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
|
81
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
82
|
+
const hex = bytes.toString("hex");
|
|
83
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Project for an account Google allocated none to. Consumer Gemini
|
|
88
|
+
* accounts are served without one, so a stable per-account value is enough;
|
|
89
|
+
* `PI_GEMINI_PROJECT_ID` pins a real one.
|
|
90
|
+
*/
|
|
91
|
+
export function fallbackProjectId(email?: string): string {
|
|
92
|
+
return geminiEnv("PROJECT_ID") ?? stableUuid(`antigravity:${email || "antigravity-default"}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The project field has moved between response shapes; accept each of them. */
|
|
96
|
+
export function extractProjectId(data: unknown): string | undefined {
|
|
97
|
+
if (!isRecord(data)) return undefined;
|
|
98
|
+
const direct = data.geminiProjectId
|
|
99
|
+
?? data.projectId
|
|
100
|
+
?? data.backendProjectId
|
|
101
|
+
?? data.userDefinedCloudaicompanionProject
|
|
102
|
+
?? data.cloudaicompanionProject
|
|
103
|
+
?? data.project;
|
|
104
|
+
if (typeof direct === "string" && direct) return direct;
|
|
105
|
+
if (isRecord(direct) && typeof direct.id === "string" && direct.id) return direct.id;
|
|
106
|
+
|
|
107
|
+
for (const key of ["projects", "projectIds", "cloudaicompanionProjects"]) {
|
|
108
|
+
const list = data[key];
|
|
109
|
+
if (!Array.isArray(list)) continue;
|
|
110
|
+
for (const item of list) {
|
|
111
|
+
if (typeof item === "string" && item) return item;
|
|
112
|
+
const nested = extractProjectId(item);
|
|
113
|
+
if (nested) return nested;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The account's project, or undefined when Google allocated none — callers
|
|
121
|
+
* then fall back to {@link fallbackProjectId}. `PI_GEMINI_PROJECT_ID` wins.
|
|
122
|
+
*/
|
|
123
|
+
export async function discoverProjectId(token: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
124
|
+
const pinned = geminiEnv("PROJECT_ID");
|
|
125
|
+
if (pinned) return pinned;
|
|
126
|
+
|
|
127
|
+
for (const endpoint of GEMINI_ENDPOINTS) {
|
|
128
|
+
const status = await postJson(endpoint, "loadCodeAssist", token, { metadata: { ideType: "ANTIGRAVITY" } }, signal);
|
|
129
|
+
if (status === undefined) continue;
|
|
130
|
+
const project = extractProjectId(status);
|
|
131
|
+
if (project) return project;
|
|
132
|
+
// The account answered but carries no project: ask for its list instead.
|
|
133
|
+
for (const listing of GEMINI_ENDPOINTS) {
|
|
134
|
+
const projects = await postJson(listing, "listCloudAICompanionProjects", token, {}, signal);
|
|
135
|
+
if (projects !== undefined) return extractProjectId(projects);
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Account label for the picker. Best effort: an account without it still works. */
|
|
143
|
+
export async function fetchUserEmail(token: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
144
|
+
try {
|
|
145
|
+
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
|
|
146
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
147
|
+
signal: timeoutSignal(signal),
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok) return undefined;
|
|
150
|
+
const email = ((await response.json()) as { email?: unknown }).email;
|
|
151
|
+
return typeof email === "string" && email ? email : undefined;
|
|
152
|
+
} catch {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** One entry of `fetchAvailableModels`, keyed by its runtime model id. */
|
|
158
|
+
export interface RuntimeModelInfo {
|
|
159
|
+
isInternal?: boolean;
|
|
160
|
+
displayName?: string;
|
|
161
|
+
label?: string;
|
|
162
|
+
modelName?: string;
|
|
163
|
+
supportsThinking?: boolean;
|
|
164
|
+
supportsImages?: boolean;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The models this account can use, merged across every endpoint so rollouts
|
|
169
|
+
* that exist only on daily or sandbox still appear. Throws only when no
|
|
170
|
+
* endpoint answered at all.
|
|
171
|
+
*/
|
|
172
|
+
export async function fetchAvailableModels(
|
|
173
|
+
token: string,
|
|
174
|
+
projectId: string,
|
|
175
|
+
signal?: AbortSignal,
|
|
176
|
+
): Promise<Record<string, RuntimeModelInfo>> {
|
|
177
|
+
const answers = await Promise.all(GEMINI_ENDPOINTS.map((endpoint) =>
|
|
178
|
+
postJson(endpoint, "fetchAvailableModels", token, { project: projectId }, signal)));
|
|
179
|
+
|
|
180
|
+
const catalogues = answers.filter(isRecord);
|
|
181
|
+
if (catalogues.length === 0) throw new Error("Gemini did not return a model list from any endpoint.");
|
|
182
|
+
|
|
183
|
+
const merged: Record<string, RuntimeModelInfo> = {};
|
|
184
|
+
for (const catalogue of catalogues) {
|
|
185
|
+
if (isRecord(catalogue.models)) Object.assign(merged, catalogue.models);
|
|
186
|
+
}
|
|
187
|
+
return merged;
|
|
188
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { OAuthCredential } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Gemini request needs two things per account: the OAuth access token
|
|
5
|
+
* and the Cloud Code project the account is served under. pi's OAuth path
|
|
6
|
+
* hands the stream only a `ModelAuth` (`apiKey`/`headers`/`baseUrl`), so the
|
|
7
|
+
* project has to travel inside one of those.
|
|
8
|
+
*
|
|
9
|
+
* It rides in `apiKey` rather than a header because a header would be sent to
|
|
10
|
+
* Google verbatim. The encoding is private to this provider: `toAuth()`
|
|
11
|
+
* produces it, the stream consumes it, and the account pool reads the token
|
|
12
|
+
* back out to attribute quota. All three share this one definition.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface GeminiCredential extends OAuthCredential {
|
|
16
|
+
/** Project discovered at login; absent when Google allocated none. */
|
|
17
|
+
projectId?: string;
|
|
18
|
+
email?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface GeminiApiKey {
|
|
22
|
+
token: string;
|
|
23
|
+
projectId: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function encodeApiKey(key: GeminiApiKey): string {
|
|
27
|
+
return JSON.stringify({ token: key.token, projectId: key.projectId });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Throws with a recoverable instruction rather than letting Google answer 401. */
|
|
31
|
+
export function decodeApiKey(apiKey: string | undefined): GeminiApiKey {
|
|
32
|
+
if (!apiKey) throw new Error("Gemini requires OAuth. Run /login gemini.");
|
|
33
|
+
|
|
34
|
+
let parsed: unknown;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(apiKey);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error("Gemini credentials are unreadable. Run /login gemini to re-authenticate.");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const { token, projectId } = (parsed ?? {}) as Partial<GeminiApiKey>;
|
|
42
|
+
if (typeof token !== "string" || !token || typeof projectId !== "string" || !projectId) {
|
|
43
|
+
throw new Error("Gemini credentials are missing a token or project. Run /login gemini.");
|
|
44
|
+
}
|
|
45
|
+
return { token, projectId };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function credentialProjectId(credential: OAuthCredential): string | undefined {
|
|
49
|
+
const value = (credential as GeminiCredential).projectId;
|
|
50
|
+
return typeof value === "string" && value ? value : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function credentialEmail(credential: OAuthCredential): string | undefined {
|
|
54
|
+
const value = (credential as GeminiCredential).email;
|
|
55
|
+
return typeof value === "string" && value ? value : undefined;
|
|
56
|
+
}
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import type { Api, Model, ModelThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
2
|
+
import { GOOGLE_MODELS } from "@earendil-works/pi-ai/providers/google.models";
|
|
3
|
+
import { GEMINI_ENDPOINT, type RuntimeModelInfo } from "./client.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Gemini's model catalogue.
|
|
7
|
+
*
|
|
8
|
+
* The backend serves each model as several *runtime* ids — one per thinking
|
|
9
|
+
* level, e.g. `gemini-3.8-flash-low` / `-medium` / `-high` — and pi exposes
|
|
10
|
+
* one public model with those levels. The runtime id for each level is stored
|
|
11
|
+
* as that level's `thinkingLevelMap` value, which is exactly what pi defines
|
|
12
|
+
* the value to be: "sent to the provider". So routing lives on the `Model`
|
|
13
|
+
* itself, survives pi's model-store persistence, and needs no side table.
|
|
14
|
+
*
|
|
15
|
+
* Facts pi already knows (name, context window, input types) come from pi's
|
|
16
|
+
* own Google catalogue where it has the model. What is Gemini's alone —
|
|
17
|
+
* which runtime id serves which level, and each family's output ceiling — is
|
|
18
|
+
* defined here.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const GEMINI_API = "gemini";
|
|
22
|
+
export const GEMINI_PROVIDER = "gemini";
|
|
23
|
+
|
|
24
|
+
export type GeminiModel = Model<typeof GEMINI_API>;
|
|
25
|
+
|
|
26
|
+
type Level = Exclude<ModelThinkingLevel, "off" | "max">;
|
|
27
|
+
type Variants = Partial<Record<Level, string>>;
|
|
28
|
+
|
|
29
|
+
const LEVELS: readonly ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
30
|
+
|
|
31
|
+
/** The subscription has already paid; nothing here is metered. */
|
|
32
|
+
const FREE: GeminiModel["cost"] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
33
|
+
|
|
34
|
+
interface Family {
|
|
35
|
+
test: RegExp;
|
|
36
|
+
contextWindow: number;
|
|
37
|
+
/** Largest `maxOutputTokens` the backend accepts; above it answers 400. */
|
|
38
|
+
maxTokens: number;
|
|
39
|
+
input: GeminiModel["input"];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Ordered: the first match wins, so the specific Gemini Pro entry precedes Gemini. */
|
|
43
|
+
const FAMILIES: readonly Family[] = [
|
|
44
|
+
{ test: /^claude-/, contextWindow: 200_000, maxTokens: 64_000, input: ["text", "image"] },
|
|
45
|
+
{ test: /^gpt-oss-/, contextWindow: 131_072, maxTokens: 32_768, input: ["text"] },
|
|
46
|
+
{ test: /^gemini-(?:.*-)?pro\b/, contextWindow: 1_048_576, maxTokens: 65_535, input: ["text", "image"] },
|
|
47
|
+
{ test: /^gemini-/, contextWindow: 1_048_576, maxTokens: 65_536, input: ["text", "image"] },
|
|
48
|
+
];
|
|
49
|
+
const UNKNOWN_FAMILY: Family = { test: /$^/, contextWindow: 128_000, maxTokens: 8_192, input: ["text"] };
|
|
50
|
+
|
|
51
|
+
const familyOf = (id: string): Family => FAMILIES.find((family) => family.test.test(id)) ?? UNKNOWN_FAMILY;
|
|
52
|
+
|
|
53
|
+
/** pi's own definition of the same model, when its Google catalogue carries it. */
|
|
54
|
+
function piModel(id: string): Model<Api> | undefined {
|
|
55
|
+
return (GOOGLE_MODELS as Record<string, Model<Api>>)[id];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface Definition {
|
|
59
|
+
id: string;
|
|
60
|
+
name: string;
|
|
61
|
+
/** Runtime id per advertised thinking level; empty for a model without thinking. */
|
|
62
|
+
variants: Variants;
|
|
63
|
+
/** Runtime id when the model has no thinking variants and differs from its public id. */
|
|
64
|
+
runtime?: string;
|
|
65
|
+
input?: GeminiModel["input"];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function defineModel({ id, name, variants, runtime, input }: Definition): GeminiModel {
|
|
69
|
+
const family = familyOf(id);
|
|
70
|
+
const known = piModel(id);
|
|
71
|
+
const reasoning = Object.keys(variants).length > 0;
|
|
72
|
+
const thinkingLevelMap: ThinkingLevelMap | undefined = reasoning
|
|
73
|
+
? Object.fromEntries(LEVELS.map((level) => [level, variants[level as Level] ?? null]))
|
|
74
|
+
: runtime && runtime !== id ? { off: runtime } : undefined;
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
id,
|
|
78
|
+
name,
|
|
79
|
+
api: GEMINI_API,
|
|
80
|
+
provider: GEMINI_PROVIDER,
|
|
81
|
+
baseUrl: GEMINI_ENDPOINT,
|
|
82
|
+
reasoning,
|
|
83
|
+
...(thinkingLevelMap && { thinkingLevelMap }),
|
|
84
|
+
input: input ?? known?.input ?? family.input,
|
|
85
|
+
cost: FREE,
|
|
86
|
+
contextWindow: known?.contextWindow ?? family.contextWindow,
|
|
87
|
+
maxTokens: Math.min(known?.maxTokens ?? family.maxTokens, family.maxTokens),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const tiered = (base: string): Variants => ({ low: `${base}-low`, medium: `${base}-medium`, high: `${base}-high` });
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The conservative baseline, selectable before the first live refresh and for
|
|
95
|
+
* accounts whose catalogue omits an entry. Mirrors `agy models`.
|
|
96
|
+
*/
|
|
97
|
+
export const STATIC_MODELS: readonly GeminiModel[] = [
|
|
98
|
+
{ id: "gemini-3.8-flash", name: "Gemini 3.8 Flash", variants: tiered("gemini-3.8-flash") },
|
|
99
|
+
{ id: "gemini-3.7-flash", name: "Gemini 3.7 Flash", variants: tiered("gemini-3.7-flash") },
|
|
100
|
+
{ id: "gemini-3.6-flash", name: "Gemini 3.6 Flash", variants: tiered("gemini-3.6-flash") },
|
|
101
|
+
{
|
|
102
|
+
// The backend labels these one step off their ids: `-extra-low` is shown
|
|
103
|
+
// as Low, `-low` as Medium, and High is a separate agent runtime.
|
|
104
|
+
id: "gemini-3.5-flash",
|
|
105
|
+
name: "Gemini 3.5 Flash",
|
|
106
|
+
variants: { low: "gemini-3.5-flash-extra-low", medium: "gemini-3.5-flash-low", high: "gemini-3-flash-agent" },
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
// `gemini-3.1-pro-high` is advertised but rejects agent requests;
|
|
110
|
+
// `gemini-pro-agent` serves High under the same display name.
|
|
111
|
+
id: "gemini-3.1-pro",
|
|
112
|
+
name: "Gemini 3.1 Pro",
|
|
113
|
+
variants: { low: "gemini-3.1-pro-low", high: "gemini-pro-agent" },
|
|
114
|
+
},
|
|
115
|
+
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", variants: { high: "claude-opus-4-6-thinking" } },
|
|
116
|
+
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", variants: { high: "claude-sonnet-4-6" } },
|
|
117
|
+
{ id: "gpt-oss-120b", name: "GPT-OSS 120B", variants: { medium: "gpt-oss-120b-medium" } },
|
|
118
|
+
].map(defineModel);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The runtime id serving `level`. With thinking off, or a level the model
|
|
122
|
+
* does not advertise, the lightest variant serves it with thinking disabled.
|
|
123
|
+
*/
|
|
124
|
+
export function runtimeModelId(model: Model<Api>, level?: ModelThinkingLevel): string {
|
|
125
|
+
const map = model.thinkingLevelMap;
|
|
126
|
+
const requested = map?.[level ?? "off"];
|
|
127
|
+
if (typeof requested === "string") return requested;
|
|
128
|
+
for (const candidate of LEVELS) {
|
|
129
|
+
const value = map?.[candidate];
|
|
130
|
+
if (typeof value === "string") return value;
|
|
131
|
+
}
|
|
132
|
+
return model.id;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface ThinkingConfig {
|
|
136
|
+
includeThoughts: boolean;
|
|
137
|
+
thinkingBudget: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
type Budgets = Record<"low" | "medium" | "high", number>;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Integer budgets per runtime family, matching the Antigravity CLI's wire
|
|
144
|
+
* format. `-1` lets Gemini decide. Ordered like {@link FAMILIES}.
|
|
145
|
+
*/
|
|
146
|
+
const BUDGETS: ReadonlyArray<readonly [RegExp, Budgets]> = [
|
|
147
|
+
[/^claude-/, { low: 1024, medium: 1024, high: 1024 }],
|
|
148
|
+
[/^gpt-oss-/, { low: 8192, medium: 8192, high: 8192 }],
|
|
149
|
+
[/^gemini-3\.5-flash|^gemini-3-flash-agent$/, { low: 1000, medium: 4000, high: 10_000 }],
|
|
150
|
+
[/^gemini-3\.1-pro|^gemini-pro-agent$/, { low: 1001, medium: 1001, high: 10_001 }],
|
|
151
|
+
[/^gemini-/, { low: 1000, medium: 4000, high: -1 }],
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
/** Undefined for a runtime family the budgets are not known for: send none. */
|
|
155
|
+
export function thinkingConfig(runtimeId: string, level?: ModelThinkingLevel): ThinkingConfig | undefined {
|
|
156
|
+
const budgets = BUDGETS.find(([test]) => test.test(runtimeId))?.[1];
|
|
157
|
+
if (!budgets) return undefined;
|
|
158
|
+
if (!level || level === "off") return { includeThoughts: false, thinkingBudget: 0 };
|
|
159
|
+
const tier = level === "medium" ? "medium" : level === "minimal" || level === "low" ? "low" : "high";
|
|
160
|
+
return { includeThoughts: true, thinkingBudget: budgets[tier] };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// --- Live catalogue ---------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/** Chat/tab autocomplete, image, and enum-placeholder entries are not agent models. */
|
|
166
|
+
export function isSelectableRuntimeId(id: string): boolean {
|
|
167
|
+
return /^(gemini-|claude-|gpt-oss-)/i.test(id)
|
|
168
|
+
&& !/\s/.test(id)
|
|
169
|
+
&& !/^(MODEL_|chat_|tab_)/i.test(id)
|
|
170
|
+
&& !/image/i.test(id);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const SUFFIXES: ReadonlyArray<readonly [string, Level]> = [
|
|
174
|
+
["extra-low", "low"],
|
|
175
|
+
["extra-high", "xhigh"],
|
|
176
|
+
["thinking", "high"],
|
|
177
|
+
["minimal", "minimal"],
|
|
178
|
+
["medium", "medium"],
|
|
179
|
+
["high", "high"],
|
|
180
|
+
["low", "low"],
|
|
181
|
+
];
|
|
182
|
+
|
|
183
|
+
const DISPLAY_LEVELS: ReadonlyArray<readonly [RegExp, Level]> = [
|
|
184
|
+
[/\(\s*extra\s*low\s*\)/i, "low"],
|
|
185
|
+
[/\(\s*extra\s*high\s*\)/i, "xhigh"],
|
|
186
|
+
[/\(\s*thinking\s*\)/i, "high"],
|
|
187
|
+
[/\(\s*minimal\s*\)/i, "minimal"],
|
|
188
|
+
[/\(\s*medium\s*\)/i, "medium"],
|
|
189
|
+
[/\(\s*high\s*\)/i, "high"],
|
|
190
|
+
[/\(\s*low\s*\)/i, "low"],
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
/** Runtime ids that share no suffix with the family they serve. */
|
|
194
|
+
const ALIASES: Record<string, readonly [string, Level]> = {
|
|
195
|
+
"gemini-3-flash-agent": ["gemini-3.5-flash", "high"],
|
|
196
|
+
"gemini-pro-agent": ["gemini-3.1-pro", "high"],
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
interface Group {
|
|
200
|
+
id: string;
|
|
201
|
+
variants: Variants;
|
|
202
|
+
unsuffixed?: string;
|
|
203
|
+
names: string[];
|
|
204
|
+
thinks?: boolean;
|
|
205
|
+
images?: boolean;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function displayName(info: RuntimeModelInfo | undefined): string | undefined {
|
|
209
|
+
const name = info?.displayName || info?.label || info?.modelName;
|
|
210
|
+
return typeof name === "string" && name ? name : undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The display name wins over the id: the backend's ids are sometimes a level off. */
|
|
214
|
+
function levelFromName(name: string | undefined): Level | undefined {
|
|
215
|
+
return name ? DISPLAY_LEVELS.find(([pattern]) => pattern.test(name))?.[1] : undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function splitSuffix(runtimeId: string): { base: string; level: Level } | undefined {
|
|
219
|
+
const lower = runtimeId.toLowerCase();
|
|
220
|
+
const match = SUFFIXES.find(([suffix]) => lower.endsWith(`-${suffix}`));
|
|
221
|
+
return match ? { base: runtimeId.slice(0, -(match[0].length + 1)), level: match[1] } : undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** "Gemini 3.9 Flash (Low)" → "gemini 3.9 flash". */
|
|
225
|
+
function displayFamily(name: string | undefined): string | undefined {
|
|
226
|
+
return name
|
|
227
|
+
?.replace(/\s*\((?:extra\s*low|extra\s*high|low|medium|high|minimal|thinking)\)\s*$/i, "")
|
|
228
|
+
.trim()
|
|
229
|
+
.toLowerCase() || undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function groupFor(groups: Map<string, Group>, id: string): Group {
|
|
233
|
+
let group = groups.get(id);
|
|
234
|
+
if (!group) groups.set(id, group = { id, variants: {}, names: [] });
|
|
235
|
+
return group;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function absorb(group: Group, info: RuntimeModelInfo | undefined, name: string | undefined): void {
|
|
239
|
+
if (name) group.names.push(name);
|
|
240
|
+
// "True anywhere" wins: one variant advertising a capability is enough.
|
|
241
|
+
if (info?.supportsThinking === true) group.thinks = true;
|
|
242
|
+
else if (info?.supportsThinking === false) group.thinks ??= false;
|
|
243
|
+
if (info?.supportsImages === true) group.images = true;
|
|
244
|
+
else if (info?.supportsImages === false) group.images ??= false;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A lone `*-agent` runtime id is a level of the family sharing its display
|
|
249
|
+
* name — High unless the name says otherwise — not a model of its own (e.g. a
|
|
250
|
+
* new `gemini-4-flash-agent` shown as "Gemini 4 Flash (High)"). The agent id
|
|
251
|
+
* wins the level: the backend advertises plain `-high` ids that reject agent
|
|
252
|
+
* requests while the agent id serves them.
|
|
253
|
+
*/
|
|
254
|
+
function mergeAgentSingletons(groups: Map<string, Group>): void {
|
|
255
|
+
// Deleting the entry being visited is well-defined for a Map iterator.
|
|
256
|
+
for (const [id, group] of groups) {
|
|
257
|
+
const own = [group.unsuffixed, ...Object.values(group.variants)].filter((value) => value !== undefined);
|
|
258
|
+
if (!id.endsWith("-agent") || own.length !== 1) continue;
|
|
259
|
+
const family = displayFamily(group.names[0]);
|
|
260
|
+
if (!family) continue;
|
|
261
|
+
const target = [...groups.values()].find((candidate) =>
|
|
262
|
+
candidate.id !== id && candidate.names.some((name) => displayFamily(name) === family));
|
|
263
|
+
if (!target) continue;
|
|
264
|
+
target.variants[levelFromName(group.names[0]) ?? "high"] = own[0];
|
|
265
|
+
absorb(target, { supportsThinking: group.thinks }, group.names[0]);
|
|
266
|
+
groups.delete(id);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** "gpt-oss-120b" → "GPT-OSS 120B", "gemini-4-0-flash" → "Gemini 4.0 Flash". */
|
|
271
|
+
export function humanizeModelId(id: string): string {
|
|
272
|
+
const tokens = id.split("-");
|
|
273
|
+
const words: string[] = [];
|
|
274
|
+
for (let index = 0; index < tokens.length; index++) {
|
|
275
|
+
const token = tokens[index];
|
|
276
|
+
const next = tokens[index + 1];
|
|
277
|
+
if (!token) continue;
|
|
278
|
+
if (token === "gpt" && next === "oss") { words.push("GPT-OSS"); index++; continue; }
|
|
279
|
+
if (/^\d+$/.test(token) && next && /^\d+$/.test(next)) { words.push(`${token}.${next}`); index++; continue; }
|
|
280
|
+
words.push(/^\d/.test(token) ? token.toUpperCase() : token.charAt(0).toUpperCase() + token.slice(1));
|
|
281
|
+
}
|
|
282
|
+
return words.join(" ");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function synthesize(group: Group): GeminiModel {
|
|
286
|
+
let variants = group.variants;
|
|
287
|
+
// No per-level ids: a thinking model is served at one level by its plain id.
|
|
288
|
+
if (Object.keys(variants).length === 0 && group.thinks !== false) {
|
|
289
|
+
variants = { high: group.unsuffixed ?? group.id };
|
|
290
|
+
}
|
|
291
|
+
const family = group.names.map(displayFamily).find(Boolean);
|
|
292
|
+
return defineModel({
|
|
293
|
+
id: group.id,
|
|
294
|
+
name: family ? family.replace(/\b([a-z])/g, (char) => char.toUpperCase()) : humanizeModelId(group.id),
|
|
295
|
+
variants,
|
|
296
|
+
runtime: group.unsuffixed,
|
|
297
|
+
...(group.images !== undefined && { input: group.images ? ["text", "image"] : ["text"] }),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function rank(id: string): readonly [number, number] {
|
|
302
|
+
const version = /^gemini-(\d+)(?:\.(\d+))?/i.exec(id);
|
|
303
|
+
const order = version ? -(Number(version[1]) * 1000 + Number(version[2] ?? 0)) : 0;
|
|
304
|
+
if (/^gemini-.*flash/i.test(id) && !/pro/i.test(id)) return [0, order];
|
|
305
|
+
if (id.startsWith("claude-opus")) return [1, 0];
|
|
306
|
+
if (id.startsWith("claude-sonnet")) return [2, 0];
|
|
307
|
+
if (id.startsWith("claude-")) return [3, 0];
|
|
308
|
+
if (/^gemini-.*pro/i.test(id)) return [4, order];
|
|
309
|
+
if (id.startsWith("gemini-")) return [5, order];
|
|
310
|
+
if (id.startsWith("gpt-oss")) return [6, 0];
|
|
311
|
+
return [7, 0];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function compareModels(left: Model<Api>, right: Model<Api>): number {
|
|
315
|
+
const [a, b] = [rank(left.id), rank(right.id)];
|
|
316
|
+
return a[0] - b[0] || a[1] - b[1] || left.id.localeCompare(right.id);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The static baseline plus whatever else `extra` carries, sorted. Static
|
|
321
|
+
* entries always win by id: their routing is verified, and a persisted
|
|
322
|
+
* catalogue from an older pi-plus must not override a corrected one.
|
|
323
|
+
*/
|
|
324
|
+
export function withStaticModels(extra: readonly Model<Api>[]): GeminiModel[] {
|
|
325
|
+
const byId = new Map<string, GeminiModel>(STATIC_MODELS.map((model) => [model.id, model]));
|
|
326
|
+
for (const model of extra) {
|
|
327
|
+
if (model.provider === GEMINI_PROVIDER && !byId.has(model.id)) byId.set(model.id, model as GeminiModel);
|
|
328
|
+
}
|
|
329
|
+
return [...byId.values()].sort(compareModels);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Groups `fetchAvailableModels` runtime ids into public pi models: newly
|
|
334
|
+
* enabled models become selectable without a pi-plus release.
|
|
335
|
+
*/
|
|
336
|
+
export function buildCatalog(runtimeModels: Record<string, RuntimeModelInfo>): GeminiModel[] {
|
|
337
|
+
const groups = new Map<string, Group>();
|
|
338
|
+
|
|
339
|
+
for (const [runtimeId, info] of Object.entries(runtimeModels)) {
|
|
340
|
+
if (!isSelectableRuntimeId(runtimeId) || info?.isInternal) continue;
|
|
341
|
+
const name = displayName(info);
|
|
342
|
+
|
|
343
|
+
if (runtimeId.endsWith("-tiered")) {
|
|
344
|
+
const group = groupFor(groups, runtimeId.slice(0, -"-tiered".length));
|
|
345
|
+
absorb(group, info, name);
|
|
346
|
+
group.unsuffixed ??= runtimeId;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const alias = ALIASES[runtimeId];
|
|
351
|
+
const suffix = alias ? undefined : splitSuffix(runtimeId);
|
|
352
|
+
const group = groupFor(groups, alias?.[0] ?? suffix?.base ?? runtimeId);
|
|
353
|
+
absorb(group, info, name);
|
|
354
|
+
|
|
355
|
+
const level = alias?.[1] ?? levelFromName(name) ?? suffix?.level;
|
|
356
|
+
if (level) group.variants[level] = runtimeId;
|
|
357
|
+
else group.unsuffixed = runtimeId;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
mergeAgentSingletons(groups);
|
|
361
|
+
return withStaticModels([...groups.values()].map(synthesize));
|
|
362
|
+
}
|