fix(provider): handle Gemini thought_signature in multi-turn tool calling and cross-model history

This commit is contained in:
Matteo Benedetto
2026-08-21 17:06:40 +02:00
parent e3362606fd
commit d42fa69e1a
2 changed files with 2767 additions and 2 deletions
+791 -2
View File
@@ -17,6 +17,18 @@ import * as path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox"; import { Type } from "typebox";
import {
type Api,
type AssistantMessage,
type AssistantMessageEventStream,
calculateCost,
type Context,
createAssistantMessageEventStream,
type Model,
type SimpleStreamOptions,
type ThinkingLevelMap,
type ToolCall,
} from "@earendil-works/pi-ai/compat";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -1404,6 +1416,692 @@ function toolUpdate(onUpdate: any, text: string) {
onUpdate?.({ content: [{ type: "text", text }] }); onUpdate?.({ content: [{ type: "text", text }] });
} }
// =========================================================================
// Client diretto Antigravity — protocollo v1internal (cloudcode-pa)
// Parla direttamente con i server di inferenza di Antigravity usando il
// token OAuth dell'account (nessun subprocess agy). Endpoint, envelope e
// flusso OAuth sono stati reverse-engineered e documentati pubblicamente
// (opencode-antigravity-auth, antigravity-proxy, torana-edge).
//
// USO CONSERVATIVO: quota per-modello (retrieveUserQuota/fetchAvailableModels),
// richieste serializzate (una alla volta), retry limitati (1 refresh + 1 retry
// su 401/403). L'uso automatizzato massiccio fa scattare il re-auth di Google
// e può portare a ban dell'account — vedi ToS Antigravity/Gemini.
// =========================================================================
const ANTG_TOKEN_FILE = path.join(os.homedir(), ".gemini", "antigravity-cli", "antigravity-oauth-token");
const ANTG_OAUTH_URL = "https://oauth2.googleapis.com/token";
const ANTG_HOSTS = ["https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com"];
const ANTG_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
const ANTG_CLIENT_SECRET = "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";
const ANTG_UA = "antigravity/cli/1.1.17";
const ANTG_API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1";
const ANTG_METADATA = JSON.stringify({ ideType: "ANTIGRAVITY", platform: "LINUX", pluginType: "GEMINI" });
interface AntgModelInfo {
backend: string;
thinkingLevel?: "low" | "medium" | "high";
}
const ANTG_MODEL_MAP: Record<string, AntgModelInfo> = {
"gemini-3.5-flash": { backend: "gemini-3.5-flash-low" },
"gemini-3.6-flash-medium": { backend: "gemini-3.6-flash-medium" },
"gemini-3.6-flash-high": { backend: "gemini-3.6-flash-high" },
"gemini-3.1-pro": { backend: "gemini-3.1-pro-low", thinkingLevel: "low" },
"gemini-3.1-pro-high": { backend: "gemini-3.1-pro-high", thinkingLevel: "high" },
"claude-sonnet-4.6": { backend: "claude-sonnet-4-6" },
"claude-opus-4.6": { backend: "claude-opus-4-6-thinking" },
"gpt-oss-120b": { backend: "gpt-oss-120b-medium" },
};
const ANTG_DEFAULT_MODEL = "gemini-3.5-flash";
let antgToken: { access: string; expiresAtMs: number; refresh: string } | null = null;
let antgProject: { pid: string; base: string } | null = null;
let antgQueue: Promise<unknown> = Promise.resolve(); // serializzazione richieste
function antgParseExpiryMs(s: string | undefined): number {
if (!s) return 0;
if (typeof s === "number") return s * 1000;
// RFC3339 con nanosecondi: tronca la frazione ai ms per Date.parse
const m = String(s).match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})?$/);
if (!m) return 0;
const ms = Date.parse(m[1] + (m[2] ? m[2].slice(0, 4) : "") + (m[3] || "Z"));
return Number.isNaN(ms) ? 0 : ms;
}
function antgReadTokenFile(): any {
try {
return JSON.parse(fs.readFileSync(ANTG_TOKEN_FILE, "utf8"));
} catch {
return null;
}
}
function antgWriteTokenFile(data: any) {
try {
const tmp = ANTG_TOKEN_FILE + ".tmp";
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
fs.renameSync(tmp, ANTG_TOKEN_FILE);
} catch {
/* ignora */
}
}
async function antgRefreshToken(refreshToken: string): Promise<{ access: string; expiresAtMs: number; refresh: string }> {
const resp = await fetch(ANTG_OAUTH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: ANTG_CLIENT_ID,
client_secret: ANTG_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: "refresh_token",
}),
signal: AbortSignal.timeout(30_000),
});
const payload: any = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(`OAuth refresh fallito (HTTP ${resp.status}): ${JSON.stringify(payload).slice(0, 200)}`);
const expiresIn = Number(payload.expires_in ?? 3600);
return {
access: payload.access_token,
expiresAtMs: Date.now() + expiresIn * 1000,
refresh: payload.refresh_token || refreshToken,
};
}
async function antgGetAccessToken(): Promise<string> {
if (antgToken && antgToken.expiresAtMs > Date.now() + 120_000) return antgToken.access;
const data = antgReadTokenFile();
if (!data?.token?.refresh_token) {
throw new Error("Nessun token OAuth Antigravity: avvia `agy` almeno una volta per autenticarti con l'account.");
}
const tok = data.token;
const expMs = antgParseExpiryMs(tok.expiry);
if (tok.access_token && expMs > Date.now() + 120_000) {
antgToken = { access: tok.access_token, expiresAtMs: expMs, refresh: tok.refresh_token };
return tok.access_token;
}
const fresh = await antgRefreshToken(tok.refresh_token);
data.token = {
...tok,
access_token: fresh.access,
token_type: "Bearer",
expiry: new Date(fresh.expiresAtMs).toISOString().replace(/\.\d{3}Z$/, ".000000Z"),
};
antgWriteTokenFile(data);
antgToken = fresh;
return fresh.access;
}
function antgDeepFind(obj: any, key: string): any {
if (obj && typeof obj === "object") {
if (key in obj) return obj[key];
for (const v of Object.values(obj)) {
const r = antgDeepFind(v, key);
if (r != null) return r;
}
}
return undefined;
}
function antgHeaders(token: string, stream = false): Record<string, string> {
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": ANTG_UA,
"X-Goog-Api-Client": ANTG_API_CLIENT,
"Client-Metadata": ANTG_METADATA,
...(stream ? { Accept: "text/event-stream" } : {}),
};
}
async function antgLoadCodeAssist(token: string, base: string): Promise<string> {
const resp = await fetch(`${base}/v1internal:loadCodeAssist`, {
method: "POST",
headers: antgHeaders(token),
body: "{}",
signal: AbortSignal.timeout(60_000),
});
const payload: any = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(`loadCodeAssist HTTP ${resp.status}`);
const pid = antgDeepFind(payload, "cloudaicompanionProject") ?? antgDeepFind(payload, "cloudaicompanion_project");
if (!pid) throw new Error("loadCodeAssist senza project id");
return String(pid);
}
async function antgGetProject(): Promise<{ pid: string; base: string }> {
if (antgProject) return antgProject;
const token = await antgGetAccessToken();
let lastErr = "";
for (const base of ANTG_HOSTS) {
try {
antgProject = { pid: await antgLoadCodeAssist(token, base), base };
return antgProject;
} catch (e: any) {
lastErr = e.message;
}
}
throw new Error(`Discovery project fallito: ${lastErr}`);
}
interface AntgGenerateOpts {
prompt: string;
model?: string;
system?: string;
maxOutputTokens?: number;
temperature?: number;
thinking?: "low" | "medium" | "high" | "off";
stream?: boolean;
}
function antgResolveModel(friendly: string | undefined): AntgModelInfo {
if (friendly && ANTG_MODEL_MAP[friendly]) return ANTG_MODEL_MAP[friendly];
if (friendly) return { backend: friendly };
return ANTG_MODEL_MAP[ANTG_DEFAULT_MODEL]!;
}
async function antgReadStream(resp: any): Promise<{ text: string; details: any }> {
const reader = resp.body?.getReader();
if (!reader) throw new Error("Nessun body streaming");
const decoder = new TextDecoder();
let buf = "", text = "", finish = "", usage: any, modelVersion = "", responseId = "", events = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
// il framing SSE usa CRLF: normalizza a \n
buf += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const chunk = buf.slice(0, i);
buf = buf.slice(i + 2);
for (const line of chunk.split("\n")) {
if (!line.startsWith("data:")) continue;
const d = line.slice(5).trim();
if (!d) continue;
events++;
try {
const o = JSON.parse(d);
const inner = o.response ?? o;
for (const pt of inner.candidates?.[0]?.content?.parts ?? []) if (pt.text) text += pt.text;
if (inner.candidates?.[0]?.finishReason) finish = inner.candidates[0].finishReason;
if (inner.usageMetadata) usage = inner.usageMetadata;
if (inner.modelVersion) modelVersion = inner.modelVersion;
if (inner.responseId) responseId = inner.responseId;
} catch {
/* evento non-JSON: ignora */
}
}
}
}
return {
text: text.trim(),
details: { finishReason: finish || undefined, usage, modelVersion, responseId, events },
};
}
async function antgGenerate(opts: AntgGenerateOpts): Promise<{ text: string; details: any }> {
// serializza: una richiesta alla volta (uso conservativo del canale account)
const run = antgQueue.then(async () => {
const { pid, base } = await antgGetProject();
const mi = antgResolveModel(opts.model);
const requestId = `agent-${crypto.randomUUID().replace(/-/g, "")}`;
const gc: any = {
maxOutputTokens: opts.maxOutputTokens ?? 8192,
temperature: opts.temperature ?? 0.4,
};
if (mi.thinkingLevel) gc.thinkingConfig = { thinkingLevel: mi.thinkingLevel };
if (opts.thinking && opts.thinking !== "off") gc.thinkingConfig = { thinkingLevel: opts.thinking };
const request: any = {
contents: [{ role: "user", parts: [{ text: opts.prompt }] }],
generationConfig: gc,
};
if (opts.system) request.systemInstruction = { parts: [{ text: opts.system }] };
const envelope = {
project: pid,
model: mi.backend,
request,
requestType: "agent",
userAgent: "antigravity",
requestId,
};
const doCall = async (token: string) => {
if (opts.stream !== false) {
const resp = await fetch(`${base}/v1internal:streamGenerateContent?alt=sse`, {
method: "POST",
headers: antgHeaders(token, true),
body: JSON.stringify(envelope),
signal: AbortSignal.timeout(300_000),
});
if (!resp.ok) throw new Error(`streamGenerateContent HTTP ${resp.status}`);
return await antgReadStream(resp);
}
const resp = await fetch(`${base}/v1internal:generateContent`, {
method: "POST",
headers: antgHeaders(token),
body: JSON.stringify(envelope),
signal: AbortSignal.timeout(180_000),
});
const raw = await resp.text();
if (!resp.ok) throw new Error(`generateContent HTTP ${resp.status}: ${raw.slice(0, 200)}`);
const p = JSON.parse(raw);
const inner = p.response ?? p;
return {
text: (inner.candidates?.[0]?.content?.parts ?? []).map((x: any) => x.text ?? "").join("").trim(),
details: {
finishReason: inner.candidates?.[0]?.finishReason,
usage: inner.usageMetadata,
modelVersion: inner.modelVersion,
responseId: inner.responseId,
},
};
};
try {
return await doCall(await antgGetAccessToken());
} catch (e: any) {
// 401/403 → un solo refresh + retry; niente loop
if (/401|403|UNAUTHENTICATED/.test(String(e.message))) {
antgToken = null;
return await doCall(await antgGetAccessToken());
}
throw e;
}
});
antgQueue = run.catch(() => undefined);
return run as Promise<{ text: string; details: any }>;
}
// =========================================================================
// Provider "antigravity" — modelli del gateway come provider pi nativo
// Registrato con pi.registerProvider() + streamSimple: appare nel selettore
// modelli (e in `pi --list-models`). Riusa il client v1internal qui sopra.
// NOTA: canale account — quota per-modello e rischio ToS/ban reale se usato
// come default con traffico continuo. Preferire per uso selettivo.
// =========================================================================
interface AntgProviderModelDef {
id: string;
name: string;
reasoning: boolean;
images: boolean;
thinkingLevelMap?: ThinkingLevelMap;
contextWindow: number;
maxTokens: number;
}
const ANTG_THINK_MAP: ThinkingLevelMap = {
off: null,
minimal: "low",
low: "low",
medium: "medium",
high: "high",
xhigh: "high",
max: "high",
};
const ANTG_PROVIDER_MODELS: AntgProviderModelDef[] = [
{ id: "gemini-3.7-flash-low", name: "Gemini 3.7 Flash (Low)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "gemini-3.7-flash-medium", name: "Gemini 3.7 Flash (Medium)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash (High)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "gemini-3.5-flash-low", name: "Gemini 3.5 Flash (Medium)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "gemini-3.6-flash-medium", name: "Gemini 3.6 Flash (Medium)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "gemini-3.6-flash-high", name: "Gemini 3.6 Flash (High)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "gemini-3.1-pro-low", name: "Gemini 3.1 Pro (Low)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro (High)", reasoning: true, images: true, thinkingLevelMap: ANTG_THINK_MAP, contextWindow: 1048576, maxTokens: 65536 },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Thinking)", reasoning: true, images: true, thinkingLevelMap: { off: null }, contextWindow: 1000000, maxTokens: 64000 },
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 (Thinking)", reasoning: true, images: true, thinkingLevelMap: { off: null }, contextWindow: 1000000, maxTokens: 64000 },
{ id: "gpt-oss-120b-medium", name: "GPT-OSS 120B (Medium)", reasoning: false, images: false, contextWindow: 262144, maxTokens: 32768 },
];
// Modelli attualmente registrati nel provider (statici all'avvio, aggiornabili
// con /agy:refresh-models dal catalogo vivo fetchAvailableModels).
let antgRegisteredModels: AntgProviderModelDef[] = ANTG_PROVIDER_MODELS;
// Configurazione del provider "antigravity" (riusata all'avvio e al refresh).
function antgProviderConfig(models: AntgProviderModelDef[]) {
return {
name: "Antigravity (account)",
baseUrl: ANTG_HOSTS[0],
apiKey: "antigravity",
api: "antigravity",
models: models.map((m) => {
const input: ("text" | "image")[] = m.images ? ["text", "image"] : ["text"];
return {
id: m.id,
name: m.name,
reasoning: m.reasoning,
thinkingLevelMap: m.thinkingLevelMap,
input,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: m.contextWindow,
maxTokens: m.maxTokens,
};
}),
streamSimple: streamAntigravity,
};
}
// Scarica il catalogo modelli vivo dall'account e lo filtra: niente modelli
// interni (displayName vuoto, chat_*, tab_*), niente placeholder, niente
// gemini-2.5-* (ritirati → HTTP 429). Restituisce la lista per il provider.
async function antgFetchCatalog(token: string, base: string): Promise<AntgProviderModelDef[]> {
const resp = await fetch(`${base}/v1internal:fetchAvailableModels`, {
method: "POST",
headers: antgHeaders(token),
body: "{}",
signal: AbortSignal.timeout(60_000),
});
const raw = await resp.text();
if (!resp.ok) throw new Error(`fetchAvailableModels HTTP ${resp.status}: ${raw.slice(0, 200)}`);
const payload = JSON.parse(raw);
const catalog: Record<string, any> = payload.models ?? {};
const out: AntgProviderModelDef[] = [];
for (const [backendId, info] of Object.entries(catalog)) {
const m = info as any;
const name = m.displayName;
if (!name || typeof name !== "string" || !name.trim()) continue; // interni/autocomplete
if (m.isInternal) continue;
if (backendId.startsWith("chat_") || backendId.startsWith("tab_")) continue;
if (backendId.includes("MODEL_PLACEHOLDER")) continue;
if (backendId.startsWith("gemini-2.5-")) continue; // ritirati (429)
const ctx = Number(m.maxTokens);
if (!ctx) continue; // modelli senza contesto (es. gemini-3.1-flash-image, generazione immagini)
const reasoning = m.supportsThinking === true;
const images = m.supportsImages === true;
const thinkingLevelMap = reasoning ? (backendId.startsWith("gemini-") ? ANTG_THINK_MAP : { off: null }) : undefined;
out.push({
id: backendId,
name,
reasoning,
images,
thinkingLevelMap,
contextWindow: ctx,
maxTokens: Number(m.maxOutputTokens) || 65536,
});
}
out.sort((a, b) => a.id.localeCompare(b.id));
return out;
}
// Campi accettati dal gateway (validazione protobuf stretta — i campi ignoti
// tipo $defs/$ref/$schema causano HTTP 400 INVALID_ARGUMENT). Perplexity +
// riproduzione locale confermano: solo type/properties/required/items/enum/…
const ANTG_SCHEMA_ALLOWED = new Set(["type", "description", "properties", "required", "items", "enum", "format", "nullable", "minimum", "maximum"]);
function antgToGeminiParameters(tschema: any): any {
try {
const root = JSON.parse(JSON.stringify(tschema));
const visit = (node: any): any => {
if (Array.isArray(node)) return node.map(visit);
if (node === null || typeof node !== "object") return node;
const out: any = {};
for (const [k, v] of Object.entries(node)) {
if (!ANTG_SCHEMA_ALLOWED.has(k)) continue;
if (k === "properties" && v && typeof v === "object" && !Array.isArray(v)) {
out.properties = Object.fromEntries(Object.entries(v as any).map(([n, c]) => [n, visit(c)]));
} else if (k === "items") {
out.items = visit(v);
} else {
out[k] = visit(v);
}
}
return out;
};
return visit(root);
} catch {
return { type: "object", properties: {} };
}
}
function antgAbortSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
if (!signal) return AbortSignal.timeout(timeoutMs);
const c = new AbortController();
const t = setTimeout(() => c.abort(), timeoutMs);
const onAbort = () => c.abort(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
c.signal.addEventListener("abort", () => {
clearTimeout(t);
signal.removeEventListener("abort", onAbort);
}, { once: true });
return c.signal;
}
function antgWithQueue<T>(fn: () => Promise<T>): Promise<T> {
const p = antgQueue.then(() => fn());
antgQueue = p.catch(() => undefined);
return p;
}
function antgBuildGeminiRequest(model: Model<Api>, context: Context, options?: SimpleStreamOptions): any {
const contents: any[] = [];
const push = (role: "user" | "model", part: any) => {
const last = contents[contents.length - 1];
if (last && last.role === role) last.parts.push(part);
else contents.push({ role, parts: [part] });
};
const signedToolCallIds = new Set<string>();
for (const msg of context.messages) {
if (msg.role === "user") {
const items = typeof msg.content === "string" ? [{ type: "text", text: msg.content }] : msg.content;
for (const b of items as any[]) {
if (b.type === "text") push("user", { text: b.text });
else if (b.type === "image") push("user", { inlineData: { mimeType: b.mimeType, data: b.data } });
}
} else if (msg.role === "assistant") {
const isSame = msg.provider === model.provider && msg.model === model.id;
const parts: any[] = [];
for (const b of msg.content) {
if (b.type === "text") {
const sig = b.textSignature || (isSame ? (b as any).thoughtSignature : undefined);
if ((!b.text || !b.text.trim()) && !sig) continue;
parts.push({ text: b.text, ...(sig ? { thoughtSignature: sig } : {}) });
} else if (b.type === "thinking") {
const sig = b.thinkingSignature || (isSame ? (b as any).thoughtSignature : undefined);
if ((!b.thinking || !b.thinking.trim()) && !sig) continue;
parts.push({ thought: true, text: b.thinking, ...(sig ? { thoughtSignature: sig } : {}) });
} else if (b.type === "toolCall") {
const sig = b.thoughtSignature || (b as any).thought_signature;
if (sig) {
if (b.id) signedToolCallIds.add(b.id);
if (b.name) signedToolCallIds.add(b.name);
parts.push({
functionCall: { name: b.name, args: b.arguments ?? {} },
thoughtSignature: sig,
});
} else {
// Chiamata non firmata (cross-model o da provider terzo): Google Gemini 2.5/3.x
// rigetta con HTTP 400 i functionCall privi di thought_signature.
// La serializziamo come testo per mantenere il contesto senza causare l'errore 400.
parts.push({
text: `[Tool Call: ${b.name}]\nArgs: ${JSON.stringify(b.arguments ?? {})}`,
});
}
}
}
for (const p of parts) push("model", p);
} else if (msg.role === "toolResult") {
const items = typeof msg.content === "string" ? [{ type: "text", text: msg.content }] : msg.content;
const text = items.map((c: any) => (c.type === "text" ? c.text : "")).join("\n");
const isSigned = (msg.toolCallId && signedToolCallIds.has(msg.toolCallId)) || signedToolCallIds.has(msg.toolName);
if (isSigned) {
push("user", { functionResponse: { name: msg.toolName, response: { result: text, isError: !!msg.isError } } });
} else {
push("user", { text: `[Tool Result: ${msg.toolName}]\n${text}` });
}
}
}
const gc: any = { maxOutputTokens: model.maxTokens || 8192 };
if (model.id.startsWith("gemini-") && model.reasoning) {
const level = options?.reasoning ?? "low";
const antgLevel = level === "low" || level === "minimal" ? "low" : level === "medium" ? "medium" : "high";
gc.thinkingConfig = { thinkingLevel: antgLevel, includeThoughts: true };
}
const request: any = { contents, generationConfig: gc };
if (context.systemPrompt) request.systemInstruction = { parts: [{ text: context.systemPrompt }] };
if (context.tools?.length) {
request.tools = [{
functionDeclarations: context.tools.map((t) => ({
name: t.name,
description: t.description,
parameters: antgToGeminiParameters(t.parameters),
})),
}];
}
return request;
}
async function antgConsumeSse(resp: any, model: Model<Api>, output: AssistantMessage, stream: AssistantMessageEventStream) {
const reader = resp.body?.getReader();
if (!reader) throw new Error("Nessun body streaming");
const decoder = new TextDecoder();
let buf = "", finishReason = "", usageMeta: any;
const blocks = output.content;
let currentBlock: any = null;
const blockIndex = () => blocks.length - 1;
const endCurrent = () => {
if (!currentBlock) return;
if (currentBlock.type === "text") stream.push({ type: "text_end", contentIndex: blockIndex(), content: currentBlock.text, partial: output });
else stream.push({ type: "thinking_end", contentIndex: blockIndex(), content: currentBlock.thinking, partial: output });
currentBlock = null;
};
let toolCallCounter = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
// il framing SSE usa CRLF: normalizza a \n
buf += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n");
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const chunk = buf.slice(0, i);
buf = buf.slice(i + 2);
for (const line of chunk.split("\n")) {
if (!line.startsWith("data:")) continue;
const d = line.slice(5).trim();
if (!d) continue;
let o: any;
try { o = JSON.parse(d); } catch { continue; }
const inner = o.response ?? o;
const candidate = inner.candidates?.[0];
if (candidate?.content?.parts) {
for (const part of candidate.content.parts) {
const sig = part.thoughtSignature || part.thought_signature || candidate.content?.thoughtSignature || candidate.content?.thought_signature;
if (part.text !== undefined) {
const isThinking = part.thought === true;
if (!currentBlock || (isThinking && currentBlock.type !== "thinking") || (!isThinking && currentBlock.type !== "text")) {
endCurrent();
if (isThinking) {
currentBlock = { type: "thinking", thinking: "", thinkingSignature: sig };
blocks.push(currentBlock);
stream.push({ type: "thinking_start", contentIndex: blockIndex(), partial: output });
} else {
currentBlock = { type: "text", text: "", textSignature: sig };
blocks.push(currentBlock);
stream.push({ type: "text_start", contentIndex: blockIndex(), partial: output });
}
}
if (currentBlock.type === "thinking") {
currentBlock.thinking += part.text;
if (sig) currentBlock.thinkingSignature = sig;
stream.push({ type: "thinking_delta", contentIndex: blockIndex(), delta: part.text, partial: output });
} else {
currentBlock.text += part.text;
if (sig) currentBlock.textSignature = sig;
stream.push({ type: "text_delta", contentIndex: blockIndex(), delta: part.text, partial: output });
}
}
if (part.functionCall) {
endCurrent();
const tc: ToolCall = {
type: "toolCall",
id: part.functionCall.id || `${part.functionCall.name}_${Date.now()}_${++toolCallCounter}`,
name: part.functionCall.name || "",
arguments: part.functionCall.args ?? {},
...(sig ? { thoughtSignature: sig } : {}),
};
blocks.push(tc);
const ci = blockIndex();
stream.push({ type: "toolcall_start", contentIndex: ci, partial: output });
stream.push({ type: "toolcall_end", contentIndex: ci, toolCall: tc, partial: output });
}
}
}
if (inner.usageMetadata) usageMeta = inner.usageMetadata;
if (inner.candidates?.[0]?.finishReason) finishReason = inner.candidates[0].finishReason;
if (inner.responseId && !output.responseId) output.responseId = inner.responseId;
}
}
}
endCurrent();
if (usageMeta) {
output.usage.input = usageMeta.promptTokenCount ?? 0;
output.usage.output = (usageMeta.candidatesTokenCount ?? 0) + (usageMeta.thoughtsTokenCount ?? 0);
output.usage.reasoning = usageMeta.thoughtsTokenCount ?? 0;
output.usage.totalTokens = usageMeta.totalTokenCount ?? (output.usage.input + output.usage.output);
output.usage.cost = calculateCost(model, output.usage);
}
output.rawStopReason = finishReason || undefined;
output.stopReason = blocks.some((b) => b.type === "toolCall") ? "toolUse" : finishReason === "MAX_TOKENS" ? "length" : "stop";
}
function streamAntigravity(model: Model<Api>, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream {
const stream = createAssistantMessageEventStream();
const output: AssistantMessage = {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "pending",
timestamp: Date.now(),
};
(async () => {
try {
stream.push({ type: "start", partial: output });
await antgWithQueue(async () => {
const { pid, base } = await antgGetProject();
const token = await antgGetAccessToken();
const envelope = {
project: pid,
model: model.id,
request: antgBuildGeminiRequest(model, context, options),
requestType: "agent",
userAgent: "antigravity",
requestId: `agent-${crypto.randomUUID().replace(/-/g, "")}`,
};
const signal = antgAbortSignal(options?.signal, 300_000);
const call = (tok: string) =>
fetch(`${base}/v1internal:streamGenerateContent?alt=sse`, {
method: "POST",
headers: antgHeaders(tok, true),
body: JSON.stringify(envelope),
signal,
});
let resp = await call(token);
if (resp.status === 401 || resp.status === 403) {
antgToken = null;
resp = await call(await antgGetAccessToken());
}
if (!resp.ok) {
const bodyText = await resp.text().catch(() => "");
throw new Error(`Antigravity HTTP ${resp.status}: ${bodyText.slice(0, 300)}`);
}
await antgConsumeSse(resp, model, output, stream);
});
if (output.stopReason === "pending") throw new Error("Provider stream terminato senza stop reason");
if (output.stopReason === "error" || output.stopReason === "aborted") throw new Error(output.errorMessage || "Errore sconosciuto");
stream.push({ type: "done", reason: output.stopReason as "stop" | "length" | "toolUse" | "deferred", message: output });
stream.end();
} catch (error) {
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
output.errorMessage = error instanceof Error ? error.message : String(error);
stream.push({ type: "error", reason: output.stopReason as "aborted" | "error", error: output });
stream.end();
}
})();
return stream;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Estensione // Estensione
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -2101,6 +2799,97 @@ export default function agyExtension(pi: ExtensionAPI) {
}, },
}); });
// =========================================================================
// TOOL: antigravity_chat — client diretto protocollo Antigravity (account)
// =========================================================================
pi.registerTool({
name: "antigravity_chat",
label: "antigravity direct protocol chat",
description:
"Chatta direttamente con i server di inferenza di Antigravity via protocollo cloudcode-pa (v1internal), " +
"usando il token OAuth dell'account (nessun subprocess agy, streaming SSE nativo). " +
"Modelli del gateway: gemini-3.5-flash (default, economico), gemini-3.6-flash-medium, gemini-3.6-flash-high, " +
"gemini-3.1-pro, gemini-3.1-pro-high, claude-sonnet-4.6, claude-opus-4.6, gpt-oss-120b. " +
"ATTENZIONE: canale account con quota per-modello e ToS Google (l'uso automatizzato massiccio può far scattare re-auth/ban). " +
"Usare con moderazione: una richiesta alla volta, niente batch automatici, preferire gemini-3.5-flash per task semplici.",
parameters: Type.Object({
prompt: Type.String({ description: "Il messaggio da inviare al modello" }),
model: Type.Optional(
Type.String({
description:
"Modello (default gemini-3.5-flash): gemini-3.5-flash | gemini-3.6-flash-medium | gemini-3.6-flash-high | gemini-3.1-pro | gemini-3.1-pro-high | claude-sonnet-4.6 | claude-opus-4.6 | gpt-oss-120b",
}),
),
system: Type.Optional(Type.String({ description: "System instruction opzionale" })),
maxOutputTokens: Type.Optional(Type.Number({ description: "Token massimi di output (default 8192)" })),
temperature: Type.Optional(Type.Number({ description: "Temperatura 0-2 (default 0.4)" })),
thinking: Type.Optional(
Type.String({
description: "Livello thinking per Gemini: low | medium | high | off (default: auto in base al modello)",
}),
),
stream: Type.Optional(Type.Boolean({ description: "Streaming SSE (default true)" })),
}),
async execute(toolCallId, params) {
const p = params as any;
try {
const res = await antgGenerate({
prompt: p.prompt,
model: p.model,
system: p.system,
maxOutputTokens: p.maxOutputTokens,
temperature: p.temperature,
thinking: p.thinking,
stream: p.stream !== false,
});
return {
content: [{ type: "text", text: res.text || "(nessun testo nella risposta)" }],
details: { ...res.details, model: p.model ?? ANTG_DEFAULT_MODEL, project: antgProject?.pid ?? null },
};
} catch (e: any) {
return {
content: [{ type: "text", text: `Errore Antigravity: ${e.message}` }],
details: {},
isError: true,
};
}
},
});
// =========================================================================
// PROVIDER: antigravity — modelli del gateway nel selettore modelli
// =========================================================================
pi.registerProvider("antigravity", antgProviderConfig(antgRegisteredModels));
// =========================================================================
// Comando: /agy:refresh-models — aggiorna l'elenco modelli del provider dal
// catalogo vivo dell'account (fetchAvailableModels) e ri-registra il provider.
// =========================================================================
pi.registerCommand("agy:refresh-models", {
description:
"Aggiorna l'elenco modelli del provider Antigravity dal catalogo vivo dell'account (fetchAvailableModels) e ri-registra il provider (selettore modelli).",
handler: async (_args, ctx) => {
ctx.ui.setStatus("agy:refresh-models", "Aggiornamento modelli Antigravity...");
try {
const { pid, base } = await antgGetProject();
const token = await antgGetAccessToken();
const models = await antgFetchCatalog(token, base);
if (!models.length) throw new Error("Il catalogo non ha restituito modelli utilizzabili");
antgRegisteredModels = models;
pi.registerProvider("antigravity", antgProviderConfig(models));
ctx.ui.setStatus("agy:refresh-models", "");
const lines = models.map((m) => ` ${m.id} — ${m.name}${m.reasoning ? " (thinking)" : ""}`).join("\n");
ctx.ui.notify(
`✅ Provider antigravity aggiornato: ${models.length} modelli (project ${pid})\n${lines}`,
"info",
);
} catch (e: any) {
ctx.ui.setStatus("agy:refresh-models", "");
ctx.ui.notify(`Errore aggiornamento modelli: ${e.message}`, "error");
}
},
});
// ========================================================================= // =========================================================================
// Comandi interattivi // Comandi interattivi
// ========================================================================= // =========================================================================
@@ -2266,8 +3055,8 @@ export default function agyExtension(pi: ExtensionAPI) {
}, },
}); });
pi.registerShortcut("ctrl+alt+c", { pi.registerShortcut("ctrl+escape", {
description: "Annulla la registrazione microfono in corso (Ctrl+Esc non funziona in legacy: invia lo stesso byte di Esc)", description: "Annulla la registrazione microfono in corso",
handler: async (ctx) => { handler: async (ctx) => {
if (recording) { if (recording) {
await cancelRecording(); await cancelRecording();
+1976
View File
File diff suppressed because it is too large Load Diff