Run the agent loop on every provider, not just API keys

The loop was gated to API-key providers, so a GPT-account (Codex) CEO failed
with 'requires an API-key provider'. Port Pi's actual request handlers natively
so every agent-capable provider runs:
- chat-completions turn for xAI, Groq, Mistral, Cerebras, OpenRouter
- Codex SSE Responses for GPT account
- Anthropic-OAuth (Claude Code system + beta headers) for Claude Pro/Max
- Copilot proxy (baseUrl from the token) for GitHub Copilot
- Gemini Code Assist for Google Antigravity

A credential resolver refreshes expired OAuth access tokens at request time and
persists the rotation; the runner routes by provider key and passes the resolved
credential. The heartbeat now retries failed tasks (bounded to 3 attempts) so a
run that failed on the old gate recovers automatically. Cloud providers
(Azure/Bedrock/Vertex) still report a clear unsupported message.
This commit is contained in:
ZacharyZhang-NY committed 2026-05-29 22:22:24 -04:00
1 parent 61f2162236
commit 937fdfa23f
16 files changed
+876 -129

No files matched your search

@@ -125,6 +125,25 @@ export class AuthBrokerService {
return this.vault.decryptSecret(Buffer.from(row.ciphertext));
}
/**
* Re-encrypts a refreshed OAuth credential into its existing credential row,
* keeping the same credential id (and active auth profile) so request-time
* token refresh persists without re-binding the provider.
*/
updateProviderSecret(input: { orgId: string; providerKey: string; credentialId: string; secret: string }): void {
const secret = input.secret.trim();
if (!secret) throw new Error("Credential is required.");
const displayHint = credentialDisplayHint(secret);
const result = this.db.prepare(`
update credentials
set ciphertext = ?, display_hint = ?
where id = ? and org_id = ? and provider_key = ?
`).run(this.vault.encryptSecret(secret), displayHint, input.credentialId, input.orgId, input.providerKey);
if (result.changes === 0) throw new Error("Provider credential was not found.");
this.db.prepare("update provider_auth_profiles set display_hint = ? where credential_id = ? and org_id = ?")
.run(displayHint, input.credentialId, input.orgId);
}
revokeProviderProfile(input: RevokeProviderAuthProfileInput): void {
const row = this.db.prepare(`
select id, credential_id
@@ -53,6 +53,22 @@ async function exchangeCode(code: string, state: string, verifier: string): Prom
return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000 };
}
/** Refresh an expired Claude Pro/Max access token. */
export async function refreshClaudePro(refreshToken: string): Promise<OAuthCredentials> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: refreshToken }),
});
const text = await response.text();
if (!response.ok) throw new Error(`Claude token refresh failed: ${response.status} ${text}`);
const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") {
throw new Error("Claude token refresh response was missing required fields.");
}
return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000 };
}
export const anthropicClaudeFlow: OAuthFlow = {
providerKey: "claude-pro",
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
@@ -9,7 +9,7 @@ import type { OAuthCredentials, OAuthFlow, OAuthLoginCallbacks } from "./oauth-t
// Decoded from the published GitHub Copilot client id used by the official CLI.
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
const COPILOT_HEADERS = {
export const COPILOT_HEADERS = {
"User-Agent": "GitHubCopilotChat/0.35.0",
"Editor-Version": "vscode/1.107.0",
"Editor-Plugin-Version": "copilot-chat/0.35.0",
@@ -177,7 +177,7 @@ export async function refreshGitHubCopilotToken(githubToken: string, enterpriseD
};
}
function copilotBaseUrl(token: string, enterpriseDomain?: string | null): string {
export function copilotBaseUrl(token: string, enterpriseDomain?: string | null): string {
const match = token.match(/proxy-ep=([^;]+)/);
if (match) return `https://${match[1].replace(/^proxy\./, "api.")}`;
if (enterpriseDomain) return `https://copilot-api.${enterpriseDomain}`;
@@ -99,6 +99,23 @@ async function discoverProject(accessToken: string): Promise<string> {
return DEFAULT_PROJECT_ID;
}
/** Refresh an expired Antigravity access token, retaining the project id. */
export async function refreshAntigravity(refreshToken: string, projectId: string): Promise<OAuthCredentials> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, refresh_token: refreshToken, grant_type: "refresh_token" }),
});
const text = await response.text();
if (!response.ok) throw new Error(`Antigravity token refresh failed: ${response.status} ${text}`);
const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
if (typeof json.access_token !== "string" || typeof json.expires_in !== "number") {
throw new Error("Antigravity token refresh response was missing required fields.");
}
const refresh = typeof json.refresh_token === "string" && json.refresh_token ? json.refresh_token : refreshToken;
return { access: json.access_token, refresh, expires: Date.now() + json.expires_in * 1000 - 5 * 60 * 1000, projectId };
}
export const antigravityFlow: OAuthFlow = {
providerKey: "google-antigravity",
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
@@ -76,6 +76,24 @@ async function exchangeCode(code: string, verifier: string): Promise<{ access: s
return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000 };
}
/** Refresh an expired Codex access token using the long-lived refresh token. */
export async function refreshCodex(refreshToken: string): Promise<OAuthCredentials> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: CLIENT_ID }),
});
const text = await response.text();
if (!response.ok) throw new Error(`Codex token refresh failed: ${response.status} ${text}`);
const json = JSON.parse(text) as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown };
if (typeof json.access_token !== "string" || typeof json.refresh_token !== "string" || typeof json.expires_in !== "number") {
throw new Error("Codex token refresh response was missing required fields.");
}
const accountId = extractAccountId(json.access_token);
if (!accountId) throw new Error("Could not read the ChatGPT account id from the refreshed token.");
return { access: json.access_token, refresh: json.refresh_token, expires: Date.now() + json.expires_in * 1000, accountId };
}
export const openaiCodexFlow: OAuthFlow = {
providerKey: "gpt-account",
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
@@ -12,8 +12,9 @@ import type { ProviderRateLimitRegistry } from "./provider-rate-limit-registry";
import type { ProviderRequestStore } from "./provider-request-store";
import type { ProviderResponseObserver } from "./provider-response-observer";
import { hashPrompt, readProviderResponseId, requireStreamText } from "./provider-response-utils";
import { requireApiKeyProfile } from "./provider-auth-profiles";
import { selectToolTurn, type AgentTurnMessage, type AgentTurnResult, type ToolDefinition, type ToolTurnFn } from "./provider-tool-turn";
import { type AgentTurnMessage, type AgentTurnResult, type ToolDefinition, type ToolTurnFn } from "./provider-tool-turn";
import { selectToolTurn, supportsToolTurn } from "./provider-tool-turns/router";
import { resolveTurnCredential } from "./provider-tool-turns/credential-resolver";
import {
HEALTH_STATE_HEALTHY,
WORKSPACE_SCOPES,
@@ -106,14 +107,14 @@ export class ProviderResponseRunner {
const provider = loadConfiguredProvider(this.deps.db, input.orgId, input.providerId);
const workspaceId = this.normalizeWorkspaceId(input.orgId, input.workspaceId ?? null);
this.enforcePolicy(input.orgId, provider, PROVIDER_EXECUTION_MODE.FOREGROUND, workspaceId);
if (provider.auth_type !== PROVIDER_AUTH_TYPE.API_KEY) throw new Error("Agent tool execution requires an API-key provider.");
if (!supportsToolTurn(provider.provider_key)) {
throw new Error("Agent execution is not available for this provider; assign the agent an OpenAI, Anthropic, xAI, Groq, Mistral, Cerebras, OpenRouter, GPT account, GitHub Copilot, Google Antigravity, or Claude Pro provider.");
}
const model = provider.model;
if (!model) throw new Error("Provider model is required.");
const owner: ProviderRow & { model: string } = { ...provider, model };
const adapter = requireAdapter(this.deps.adapters, provider.provider_key, provider.auth_type);
const turn = this.deps.toolTurn ?? selectToolTurn(provider.provider_key);
const credential = loadCredential(this.deps.authBroker, input.orgId, provider);
const apiKey = requireApiKeyProfile(adapter.parseAuthProfile(credential.secret), provider.provider_key).apiKey;
const credential = await resolveTurnCredential({ authBroker: this.deps.authBroker, orgId: input.orgId, provider });
const promptChars = JSON.stringify(input.messages).length;
const requestId = this.deps.requestStore.create({
orgId: input.orgId,
@@ -127,7 +128,7 @@ export class ProviderResponseRunner {
});
const startedAt = Date.now();
try {
const result = await turn({ apiKey, model, instructions: input.instructions, messages: input.messages, tools: input.tools });
const result = await turn({ credential, model, instructions: input.instructions, messages: input.messages, tools: input.tools });
this.deps.requestStore.markSucceeded({
orgId: input.orgId,
requestId,
@@ -1,13 +1,12 @@
/**
* Provider-agnostic single turn of a tool-use ("function calling") conversation.
*
* The agent loop maintains a normalized conversation ({@link AgentTurnMessage}[])
* and the requested {@link ToolDefinition}s; these helpers translate that into the
* provider's wire format (OpenAI Responses API or Anthropic Messages API), call
* the live endpoint, and parse the assistant's reply plus any tool calls back into
* the normalized shape.
* Shared contract for a single tool-use ("function calling") turn against any
* provider backend. The agent loop maintains a normalized conversation
* ({@link AgentTurnMessage}[]) plus the requested {@link ToolDefinition}s; each
* provider's turn function (under `provider-tool-turns/`) translates that into
* the backend's wire format, calls the live endpoint, and parses the reply plus
* any tool calls back into {@link AgentTurnResult}.
*/
import { isRecord, postJson, type JsonRecord } from "./provider-http";
import { isRecord, type JsonRecord } from "./provider-http";
export interface ToolDefinition {
name: string;
@@ -33,15 +32,27 @@ export interface AgentTurnResult {
rawResponseJson: string;
}
/** Resolved per-request credential, already refreshed if it was an OAuth token. */
export interface TurnCredential {
apiKey?: string;
accessToken?: string;
accountId?: string;
projectId?: string;
enterpriseUrl?: string | null;
}
export interface ProviderToolTurnInput {
apiKey: string;
credential: TurnCredential;
model: string;
instructions: string | null;
messages: AgentTurnMessage[];
tools: ToolDefinition[];
}
function parseArguments(value: unknown): JsonRecord {
export type ToolTurnFn = (input: ProviderToolTurnInput) => Promise<AgentTurnResult>;
/** Coerce a tool-call argument payload (string or object) into a JSON object. */
export function parseArguments(value: unknown): JsonRecord {
if (isRecord(value)) return value;
if (typeof value === "string" && value.trim()) {
try {
@@ -53,112 +64,3 @@ function parseArguments(value: unknown): JsonRecord {
}
return {};
}
// ---- OpenAI Responses API ----
function toOpenAiInput(messages: AgentTurnMessage[]): unknown[] {
const items: unknown[] = [];
for (const message of messages) {
if (message.role === "user") {
items.push({ role: "user", content: message.text });
} else if (message.role === "assistant") {
for (const call of message.toolCalls) {
items.push({ type: "function_call", call_id: call.id, name: call.name, arguments: JSON.stringify(call.arguments) });
}
} else {
for (const result of message.results) {
items.push({ type: "function_call_output", call_id: result.toolCallId, output: result.output });
}
}
}
return items;
}
function parseOpenAiResult(data: JsonRecord): AgentTurnResult {
const output = Array.isArray(data.output) ? data.output : [];
const textParts: string[] = [];
const toolCalls: ToolCall[] = [];
for (const item of output) {
if (!isRecord(item)) continue;
if (item.type === "function_call") {
const callId = typeof item.call_id === "string" ? item.call_id : typeof item.id === "string" ? item.id : "";
const name = typeof item.name === "string" ? item.name : "";
if (callId && name) toolCalls.push({ id: callId, name, arguments: parseArguments(item.arguments) });
} else if (item.type === "message" && Array.isArray(item.content)) {
for (const block of item.content) {
if (isRecord(block) && block.type === "output_text" && typeof block.text === "string") textParts.push(block.text);
}
}
}
return { text: textParts.join("\n").trim(), toolCalls, rawResponseJson: JSON.stringify(data) };
}
export async function runOpenAiToolTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const body: JsonRecord = {
model: input.model,
input: toOpenAiInput(input.messages),
tools: input.tools.map((tool) => ({ type: "function", name: tool.name, description: tool.description, parameters: tool.parameters })),
tool_choice: "auto",
...(input.instructions ? { instructions: input.instructions } : {}),
};
const response = await postJson("https://api.openai.com/v1/responses", { authorization: `Bearer ${input.apiKey}` }, body);
return parseOpenAiResult(response.data);
}
// ---- Anthropic Messages API ----
function toAnthropicMessages(messages: AgentTurnMessage[]): unknown[] {
const out: unknown[] = [];
for (const message of messages) {
if (message.role === "user") {
out.push({ role: "user", content: [{ type: "text", text: message.text }] });
} else if (message.role === "assistant") {
const content: unknown[] = [];
if (message.text) content.push({ type: "text", text: message.text });
for (const call of message.toolCalls) content.push({ type: "tool_use", id: call.id, name: call.name, input: call.arguments });
out.push({ role: "assistant", content });
} else {
out.push({ role: "user", content: message.results.map((result) => ({ type: "tool_result", tool_use_id: result.toolCallId, content: result.output })) });
}
}
return out;
}
function parseAnthropicResult(data: JsonRecord): AgentTurnResult {
const content = Array.isArray(data.content) ? data.content : [];
const textParts: string[] = [];
const toolCalls: ToolCall[] = [];
for (const block of content) {
if (!isRecord(block)) continue;
if (block.type === "text" && typeof block.text === "string") textParts.push(block.text);
else if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") {
toolCalls.push({ id: block.id, name: block.name, arguments: isRecord(block.input) ? block.input : {} });
}
}
return { text: textParts.join("\n").trim(), toolCalls, rawResponseJson: JSON.stringify(data) };
}
export async function runAnthropicToolTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const body: JsonRecord = {
model: input.model,
max_tokens: 4096,
tools: input.tools.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.parameters })),
messages: toAnthropicMessages(input.messages),
...(input.instructions ? { system: input.instructions } : {}),
};
const response = await postJson("https://api.anthropic.com/v1/messages", { "x-api-key": input.apiKey, "anthropic-version": "2023-06-01" }, body);
return parseAnthropicResult(response.data);
}
export type ToolTurnFn = (input: ProviderToolTurnInput) => Promise<AgentTurnResult>;
/** Provider keys that can drive a native tool-use loop, by wire protocol. */
export function selectToolTurn(providerKey: string): ToolTurnFn {
if (providerKey === "openai") return runOpenAiToolTurn;
if (providerKey === "anthropic") return runAnthropicToolTurn;
throw new Error(`${providerKey} does not support agent tool execution; assign the agent an OpenAI or Anthropic provider.`);
}
export function supportsToolTurn(providerKey: string): boolean {
return providerKey === "openai" || providerKey === "anthropic";
}
@@ -0,0 +1,38 @@
/** Anthropic Messages API encoding/decoding, shared by anthropic + claude-pro. */
import { isRecord, type JsonRecord } from "../provider-http";
import { type AgentTurnMessage, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
export function anthropicToolDefs(tools: ToolDefinition[]): unknown[] {
return tools.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.parameters }));
}
export function toAnthropicMessages(messages: AgentTurnMessage[]): unknown[] {
const out: unknown[] = [];
for (const message of messages) {
if (message.role === "user") {
out.push({ role: "user", content: [{ type: "text", text: message.text }] });
} else if (message.role === "assistant") {
const content: unknown[] = [];
if (message.text) content.push({ type: "text", text: message.text });
for (const call of message.toolCalls) content.push({ type: "tool_use", id: call.id, name: call.name, input: call.arguments });
out.push({ role: "assistant", content });
} else {
out.push({ role: "user", content: message.results.map((result) => ({ type: "tool_result", tool_use_id: result.toolCallId, content: result.output })) });
}
}
return out;
}
export function parseAnthropicContent(data: JsonRecord): { text: string; toolCalls: ToolCall[] } {
const content = Array.isArray(data.content) ? data.content : [];
const textParts: string[] = [];
const toolCalls: ToolCall[] = [];
for (const block of content) {
if (!isRecord(block)) continue;
if (block.type === "text" && typeof block.text === "string") textParts.push(block.text);
else if (block.type === "tool_use" && typeof block.id === "string" && typeof block.name === "string") {
toolCalls.push({ id: block.id, name: block.name, arguments: isRecord(block.input) ? block.input : {} });
}
}
return { text: textParts.join("\n").trim(), toolCalls };
}
@@ -0,0 +1,46 @@
/** OpenAI Chat Completions encoding/decoding (xAI, Groq, Mistral, Cerebras, OpenRouter). */
import { isRecord, type JsonRecord } from "../provider-http";
import { parseArguments, type AgentTurnMessage, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
export function chatToolDefs(tools: ToolDefinition[]): unknown[] {
return tools.map((tool) => ({ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters } }));
}
export function toChatMessages(instructions: string | null, messages: AgentTurnMessage[]): unknown[] {
const out: unknown[] = [];
if (instructions) out.push({ role: "system", content: instructions });
for (const message of messages) {
if (message.role === "user") {
out.push({ role: "user", content: message.text });
} else if (message.role === "assistant") {
const entry: JsonRecord = { role: "assistant", content: message.text || null };
if (message.toolCalls.length > 0) {
entry.tool_calls = message.toolCalls.map((call) => ({ id: call.id, type: "function", function: { name: call.name, arguments: JSON.stringify(call.arguments) } }));
}
out.push(entry);
} else {
for (const result of message.results) {
out.push({ role: "tool", tool_call_id: result.toolCallId, content: result.output });
}
}
}
return out;
}
export function parseChatChoice(data: JsonRecord): { text: string; toolCalls: ToolCall[] } {
const choices = Array.isArray(data.choices) ? data.choices : [];
const first = choices.length > 0 && isRecord(choices[0]) ? choices[0] : null;
const message = first && isRecord(first.message) ? first.message : null;
if (!message) return { text: "", toolCalls: [] };
const text = typeof message.content === "string" ? message.content : "";
const rawCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
const toolCalls: ToolCall[] = [];
for (const call of rawCalls) {
if (!isRecord(call)) continue;
const fn = isRecord(call.function) ? call.function : null;
const id = typeof call.id === "string" ? call.id : "";
const name = fn && typeof fn.name === "string" ? fn.name : "";
if (id && name) toolCalls.push({ id, name, arguments: parseArguments(fn?.arguments) });
}
return { text: text.trim(), toolCalls };
}
@@ -0,0 +1,88 @@
/**
* Resolves the per-request credential for a tool-use turn. API-key providers
* pass the key straight through; OAuth/subscription providers parse the stored
* credential JSON, refresh the access token when it has expired (persisting the
* rotated token), and hand the turn a usable access token plus the extras
* (accountId / projectId / enterpriseUrl) its backend needs.
*/
import { PROVIDER_AUTH_TYPE } from "../../../shared/domain";
import type { AuthBrokerService } from "../auth-broker-service";
import { loadCredential, type ProviderRow } from "../provider-internal";
import { parseApiKey, requireApiKeyProfile } from "../provider-auth-profiles";
import type { TurnCredential } from "../provider-tool-turn";
import type { OAuthCredentials } from "../oauth/oauth-types";
import { refreshCodex } from "../oauth/openai-codex-flow";
import { refreshClaudePro } from "../oauth/anthropic-claude-flow";
import { refreshAntigravity } from "../oauth/google-antigravity-flow";
import { refreshGitHubCopilotToken } from "../oauth/github-copilot-flow";
interface StoredOAuth {
access: string;
refresh: string;
expires: number;
accountId?: string;
projectId?: string;
enterpriseUrl?: string | null;
}
function parseStoredOAuth(secret: string): StoredOAuth {
let parsed: unknown;
try {
parsed = JSON.parse(secret);
} catch {
throw new Error("Stored OAuth credential is not valid JSON; reconnect the provider.");
}
const creds = parsed as StoredOAuth;
if (typeof creds.access !== "string" || typeof creds.refresh !== "string" || typeof creds.expires !== "number") {
throw new Error("Stored OAuth credential is incomplete; reconnect the provider.");
}
return creds;
}
function refreshOAuth(providerKey: string, creds: StoredOAuth): Promise<OAuthCredentials> {
switch (providerKey) {
case "gpt-account":
return refreshCodex(creds.refresh);
case "claude-pro":
return refreshClaudePro(creds.refresh);
case "google-antigravity":
if (!creds.projectId) throw new Error("Antigravity credential is missing its project id; reconnect the provider.");
return refreshAntigravity(creds.refresh, creds.projectId);
case "github-copilot":
return refreshGitHubCopilotToken(creds.refresh, creds.enterpriseUrl ?? undefined);
default:
throw new Error(`${providerKey} cannot refresh its credential.`);
}
}
function toTurnCredential(creds: StoredOAuth | OAuthCredentials): TurnCredential {
const enterpriseUrl = creds.enterpriseUrl;
return {
accessToken: creds.access,
accountId: typeof creds.accountId === "string" ? creds.accountId : undefined,
projectId: typeof creds.projectId === "string" ? creds.projectId : undefined,
enterpriseUrl: typeof enterpriseUrl === "string" ? enterpriseUrl : enterpriseUrl === null ? null : undefined,
};
}
export async function resolveTurnCredential(deps: {
authBroker: AuthBrokerService;
orgId: string;
provider: ProviderRow;
}): Promise<TurnCredential> {
const secret = loadCredential(deps.authBroker, deps.orgId, deps.provider).secret;
if (deps.provider.auth_type === PROVIDER_AUTH_TYPE.API_KEY) {
return { apiKey: requireApiKeyProfile(parseApiKey(secret), deps.provider.provider_key).apiKey };
}
const creds = parseStoredOAuth(secret);
if (Date.now() < creds.expires) return toTurnCredential(creds);
if (!deps.provider.credential_id) throw new Error("Provider credential id is missing; reconnect the provider.");
const refreshed = await refreshOAuth(deps.provider.provider_key, creds);
deps.authBroker.updateProviderSecret({
orgId: deps.orgId,
providerKey: deps.provider.provider_key,
credentialId: deps.provider.credential_id,
secret: JSON.stringify(refreshed),
});
return toTurnCredential(refreshed);
}
@@ -0,0 +1,117 @@
/**
* Google Cloud Code Assist (Gemini) encoding for the Antigravity backend.
* Ported from Pi's google-shared `convertMessages` / `convertTools` and the
* Antigravity request wrapper in google-gemini-cli.
*/
import { isRecord, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http";
import { type AgentTurnMessage, type AgentTurnResult, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
const ANTIGRAVITY_SYSTEM_INSTRUCTION =
"You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding." +
"You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question." +
"**Absolute paths only**" +
"**Proactiveness**";
function usesToolCallId(model: string): boolean {
return model.startsWith("claude-") || model.startsWith("gpt-oss-");
}
function geminiToolDefs(model: string, tools: ToolDefinition[]): unknown[] {
const useParameters = model.startsWith("claude-");
return [
{
functionDeclarations: tools.map((tool) => ({
name: tool.name,
description: tool.description,
...(useParameters ? { parameters: tool.parameters } : { parametersJsonSchema: tool.parameters }),
})),
},
];
}
function toGeminiContents(model: string, messages: AgentTurnMessage[]): JsonRecord[] {
const withId = usesToolCallId(model);
const contents: JsonRecord[] = [];
for (const message of messages) {
if (message.role === "user") {
contents.push({ role: "user", parts: [{ text: message.text }] });
} else if (message.role === "assistant") {
const parts: unknown[] = [];
if (message.text) parts.push({ text: message.text });
for (const call of message.toolCalls) {
parts.push({ functionCall: { name: call.name, args: call.arguments, ...(withId ? { id: call.id } : {}) } });
}
if (parts.length > 0) contents.push({ role: "model", parts });
} else {
const responses = message.results.map((result) => ({
functionResponse: { name: result.toolName, response: { output: result.output }, ...(withId ? { id: result.toolCallId } : {}) },
}));
const last = contents[contents.length - 1];
if (last && last.role === "user" && Array.isArray(last.parts) && last.parts.some((part) => isRecord(part) && "functionResponse" in part)) {
(last.parts as unknown[]).push(...responses);
} else {
contents.push({ role: "user", parts: responses });
}
}
}
return contents;
}
/** The full Antigravity request envelope ({project, model, request, ...}). */
export function antigravityRequestBody(input: {
model: string;
projectId: string;
instructions: string | null;
messages: AgentTurnMessage[];
tools: ToolDefinition[];
requestId: string;
}): JsonRecord {
const request: JsonRecord = {
contents: toGeminiContents(input.model, input.messages),
systemInstruction: {
role: "user",
parts: [
{ text: ANTIGRAVITY_SYSTEM_INSTRUCTION },
{ text: `Please ignore following [ignore]${ANTIGRAVITY_SYSTEM_INSTRUCTION}[/ignore]` },
...(input.instructions ? [{ text: input.instructions }] : []),
],
},
};
if (input.tools.length > 0) {
request.tools = geminiToolDefs(input.model, input.tools);
request.toolConfig = { functionCallingConfig: { mode: "AUTO" } };
}
return { project: input.projectId, model: input.model, request, requestType: "agent", userAgent: "antigravity", requestId: input.requestId };
}
export async function accumulateGeminiStream(messages: AsyncIterable<ProviderEventStreamMessage>): Promise<AgentTurnResult> {
const raw: string[] = [];
const textParts: string[] = [];
const toolCalls: ToolCall[] = [];
let counter = 0;
for await (const message of messages) {
raw.push(message.rawData);
if (isRecord(message.data.error)) {
const error = message.data.error;
throw new Error(typeof error.message === "string" ? error.message : "Antigravity stream failed.");
}
const response = isRecord(message.data.response) ? message.data.response : null;
if (!response) continue;
const candidates = Array.isArray(response.candidates) ? response.candidates : [];
const content = candidates.length > 0 && isRecord(candidates[0]) && isRecord(candidates[0].content) ? candidates[0].content : null;
const parts = content && Array.isArray(content.parts) ? content.parts : [];
for (const part of parts) {
if (!isRecord(part)) continue;
if (typeof part.text === "string" && part.thought !== true) {
textParts.push(part.text);
} else if (isRecord(part.functionCall)) {
const fc = part.functionCall;
const name = typeof fc.name === "string" ? fc.name : "";
if (!name) continue;
const id = typeof fc.id === "string" && fc.id ? fc.id : `${name}_${(counter += 1)}`;
toolCalls.push({ id, name, arguments: isRecord(fc.args) ? fc.args : {} });
}
}
}
return { text: textParts.join("").trim(), toolCalls, rawResponseJson: JSON.stringify(raw) };
}
@@ -0,0 +1,106 @@
/**
* OpenAI Responses API encoding/decoding, shared by the openai, gpt-account
* (Codex) and github-copilot turns. Matches Pi's `convertResponsesMessages` /
* `convertResponsesTools` and the Responses streaming event shapes.
*/
import { isRecord, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http";
import { parseArguments, type AgentTurnMessage, type AgentTurnResult, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
export function responsesToolDefs(tools: ToolDefinition[], strict: boolean | null = false): unknown[] {
return tools.map((tool) => ({ type: "function", name: tool.name, description: tool.description, parameters: tool.parameters, strict }));
}
export function toResponsesInput(messages: AgentTurnMessage[]): unknown[] {
const items: unknown[] = [];
for (const message of messages) {
if (message.role === "user") {
items.push({ role: "user", content: [{ type: "input_text", text: message.text }] });
} else if (message.role === "assistant") {
if (message.text) {
items.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: message.text, annotations: [] }] });
}
for (const call of message.toolCalls) {
items.push({ type: "function_call", call_id: call.id, name: call.name, arguments: JSON.stringify(call.arguments) });
}
} else {
for (const result of message.results) {
items.push({ type: "function_call_output", call_id: result.toolCallId, output: result.output });
}
}
}
return items;
}
/** Parse a non-streaming Responses payload (`data.output[]`). */
export function parseResponsesOutput(data: JsonRecord): { text: string; toolCalls: ToolCall[] } {
const output = Array.isArray(data.output) ? data.output : [];
const textParts: string[] = [];
const toolCalls: ToolCall[] = [];
for (const item of output) {
if (!isRecord(item)) continue;
if (item.type === "function_call") {
const id = typeof item.call_id === "string" ? item.call_id : typeof item.id === "string" ? item.id : "";
const name = typeof item.name === "string" ? item.name : "";
if (id && name) toolCalls.push({ id, name, arguments: parseArguments(item.arguments) });
} else if (item.type === "message" && Array.isArray(item.content)) {
for (const block of item.content) {
if (isRecord(block) && block.type === "output_text" && typeof block.text === "string") textParts.push(block.text);
}
}
}
return { text: textParts.join("\n").trim(), toolCalls };
}
function eventType(message: ProviderEventStreamMessage): string | null {
const type = message.data.type;
return typeof type === "string" ? type : message.eventName;
}
function errorText(data: JsonRecord): string {
const error = data.error;
if (typeof error === "string") return error;
if (isRecord(error) && typeof error.message === "string") return error.message;
if (typeof data.message === "string") return data.message;
return "Provider stream failed.";
}
/**
* Accumulate a Responses SSE stream (Codex/Copilot) into the final result. The
* authoritative output arrives on the completion event's `response.output`; the
* incremental events are accumulated only as a fallback.
*/
export async function accumulateResponsesStream(messages: AsyncIterable<ProviderEventStreamMessage>): Promise<AgentTurnResult> {
const raw: string[] = [];
const textParts: string[] = [];
const calls = new Map<string, { id: string; name: string; args: string }>();
for await (const message of messages) {
raw.push(message.rawData);
const type = eventType(message);
if (!type) continue;
if (type === "error" || type === "response.failed") throw new Error(errorText(message.data));
if (type === "response.completed" || type === "response.done" || type === "response.incomplete") {
const response = isRecord(message.data.response) ? message.data.response : null;
if (response && Array.isArray(response.output)) {
const parsed = parseResponsesOutput(response);
return { text: parsed.text, toolCalls: parsed.toolCalls, rawResponseJson: JSON.stringify(raw) };
}
break;
}
if (type === "response.output_text.delta" && typeof message.data.delta === "string") {
textParts.push(message.data.delta);
} else if (type === "response.output_item.added" && isRecord(message.data.item) && message.data.item.type === "function_call") {
const item = message.data.item;
const key = typeof item.id === "string" ? item.id : typeof item.call_id === "string" ? item.call_id : "";
const id = typeof item.call_id === "string" ? item.call_id : key;
if (key) calls.set(key, { id, name: typeof item.name === "string" ? item.name : "", args: typeof item.arguments === "string" ? item.arguments : "" });
} else if (type === "response.function_call_arguments.delta" && typeof message.data.item_id === "string" && typeof message.data.delta === "string") {
const entry = calls.get(message.data.item_id);
if (entry) entry.args += message.data.delta;
} else if (type === "response.function_call_arguments.done" && typeof message.data.item_id === "string" && typeof message.data.arguments === "string") {
const entry = calls.get(message.data.item_id);
if (entry) entry.args = message.data.arguments;
}
}
const toolCalls = [...calls.values()].filter((entry) => entry.name).map((entry) => ({ id: entry.id, name: entry.name, arguments: parseArguments(entry.args) }));
return { text: textParts.join("").trim(), toolCalls, rawResponseJson: JSON.stringify(raw) };
}
@@ -0,0 +1,35 @@
import type { ToolTurnFn } from "../provider-tool-turn";
import {
makeChatCompletionsTurn,
runAnthropicTurn,
runAntigravityTurn,
runClaudeProTurn,
runCodexTurn,
runCopilotTurn,
runOpenAiResponsesTurn,
} from "./turns";
/** Provider key -> the tool-use turn function that drives the agent loop. */
const TURNS: Record<string, ToolTurnFn> = {
openai: runOpenAiResponsesTurn,
anthropic: runAnthropicTurn,
xai: makeChatCompletionsTurn("https://api.x.ai/v1/chat/completions", "xAI"),
groq: makeChatCompletionsTurn("https://api.groq.com/openai/v1/chat/completions", "Groq"),
mistral: makeChatCompletionsTurn("https://api.mistral.ai/v1/chat/completions", "Mistral"),
cerebras: makeChatCompletionsTurn("https://api.cerebras.ai/v1/chat/completions", "Cerebras"),
openrouter: makeChatCompletionsTurn("https://openrouter.ai/api/v1/chat/completions", "OpenRouter"),
"gpt-account": runCodexTurn,
"github-copilot": runCopilotTurn,
"google-antigravity": runAntigravityTurn,
"claude-pro": runClaudeProTurn,
};
export function supportsToolTurn(providerKey: string): boolean {
return providerKey in TURNS;
}
export function selectToolTurn(providerKey: string): ToolTurnFn {
const turn = TURNS[providerKey];
if (!turn) throw new Error(`${providerKey} does not support agent execution.`);
return turn;
}
@@ -0,0 +1,176 @@
/**
* Per-backend tool-use turn functions. Each takes the normalized
* {@link ProviderToolTurnInput} (with a resolved credential) and returns an
* {@link AgentTurnResult}. Faithful ports of Pi's provider request handlers.
*/
import { release as osRelease, arch as osArch, platform as osPlatform } from "node:os";
import { postEventStream, postJson, type JsonRecord } from "../provider-http";
import type { AgentTurnMessage, AgentTurnResult, ProviderToolTurnInput, TurnCredential } from "../provider-tool-turn";
import { accumulateResponsesStream, parseResponsesOutput, responsesToolDefs, toResponsesInput } from "./responses-format";
import { anthropicToolDefs, parseAnthropicContent, toAnthropicMessages } from "./anthropic-format";
import { chatToolDefs, parseChatChoice, toChatMessages } from "./chat-format";
import { accumulateGeminiStream, antigravityRequestBody } from "./gemini-format";
import { COPILOT_HEADERS, copilotBaseUrl } from "../oauth/github-copilot-flow";
const ANTIGRAVITY_VERSION = "1.21.9";
const ANTIGRAVITY_ENDPOINT = "https://cloudcode-pa.googleapis.com";
const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
function requireApiKey(credential: TurnCredential, label: string): string {
if (!credential.apiKey) throw new Error(`${label} requires an API key.`);
return credential.apiKey;
}
function requireAccessToken(credential: TurnCredential, label: string): string {
if (!credential.accessToken) throw new Error(`${label} requires a signed-in access token.`);
return credential.accessToken;
}
function lastMessageIsUser(messages: AgentTurnMessage[]): boolean {
const last = messages[messages.length - 1];
return last?.role === "user";
}
// ---- OpenAI Responses (api key) ----
export async function runOpenAiResponsesTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const body: JsonRecord = {
model: input.model,
input: toResponsesInput(input.messages),
tools: responsesToolDefs(input.tools),
tool_choice: "auto",
store: false,
...(input.instructions ? { instructions: input.instructions } : {}),
};
const response = await postJson("https://api.openai.com/v1/responses", { authorization: `Bearer ${requireApiKey(input.credential, "OpenAI")}` }, body);
const parsed = parseResponsesOutput(response.data);
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
}
// ---- OpenAI Chat Completions (xAI, Groq, Mistral, Cerebras, OpenRouter) ----
export function makeChatCompletionsTurn(endpoint: string, label: string): (input: ProviderToolTurnInput) => Promise<AgentTurnResult> {
return async (input) => {
const body: JsonRecord = {
model: input.model,
messages: toChatMessages(input.instructions, input.messages),
tools: chatToolDefs(input.tools),
tool_choice: "auto",
stream: false,
};
const response = await postJson(endpoint, { authorization: `Bearer ${requireApiKey(input.credential, label)}` }, body);
const parsed = parseChatChoice(response.data);
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
};
}
// ---- Anthropic Messages (api key) ----
export async function runAnthropicTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const body: JsonRecord = {
model: input.model,
max_tokens: 4096,
tools: anthropicToolDefs(input.tools),
messages: toAnthropicMessages(input.messages),
...(input.instructions ? { system: input.instructions } : {}),
};
const response = await postJson("https://api.anthropic.com/v1/messages", { "x-api-key": requireApiKey(input.credential, "Anthropic"), "anthropic-version": "2023-06-01" }, body);
const parsed = parseAnthropicContent(response.data);
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
}
// ---- Claude Pro/Max (OAuth subscription) ----
export async function runClaudeProTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const system: unknown[] = [
{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
...(input.instructions ? [{ type: "text", text: input.instructions }] : []),
];
const body: JsonRecord = {
model: input.model,
max_tokens: 4096,
tools: anthropicToolDefs(input.tools),
messages: toAnthropicMessages(input.messages),
system,
};
const response = await postJson("https://api.anthropic.com/v1/messages", {
authorization: `Bearer ${requireAccessToken(input.credential, "Claude Pro")}`,
"anthropic-version": "2023-06-01",
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14",
"anthropic-dangerous-direct-browser-access": "true",
"user-agent": "claude-cli/2.1.75",
"x-app": "cli",
}, body);
const parsed = parseAnthropicContent(response.data);
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
}
// ---- GPT account / OpenAI Codex (OAuth, SSE) ----
export async function runCodexTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const access = requireAccessToken(input.credential, "GPT account");
if (!input.credential.accountId) throw new Error("GPT account credential is missing its account id.");
const body: JsonRecord = {
model: input.model,
store: false,
stream: true,
...(input.instructions ? { instructions: input.instructions } : {}),
input: toResponsesInput(input.messages),
text: { verbosity: "medium" },
include: ["reasoning.encrypted_content"],
tool_choice: "auto",
parallel_tool_calls: true,
tools: responsesToolDefs(input.tools, null),
};
const response = await postEventStream(`${CODEX_BASE_URL}/codex/responses`, {
Authorization: `Bearer ${access}`,
"chatgpt-account-id": input.credential.accountId,
originator: "agentcompany",
"User-Agent": `agentcompany (${osPlatform()} ${osRelease()}; ${osArch()})`,
"OpenAI-Beta": "responses=experimental",
}, body);
return accumulateResponsesStream(response.messages);
}
// ---- GitHub Copilot (OAuth, openai-responses at the proxy) ----
export async function runCopilotTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const access = requireAccessToken(input.credential, "GitHub Copilot");
const baseUrl = copilotBaseUrl(access, input.credential.enterpriseUrl ?? undefined);
const body: JsonRecord = {
model: input.model,
input: toResponsesInput(input.messages),
tools: responsesToolDefs(input.tools),
tool_choice: "auto",
store: false,
};
const response = await postJson(`${baseUrl}/responses`, {
Authorization: `Bearer ${access}`,
...COPILOT_HEADERS,
"X-Initiator": lastMessageIsUser(input.messages) ? "user" : "agent",
"Openai-Intent": "conversation-edits",
}, body);
const parsed = parseResponsesOutput(response.data);
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
}
// ---- Google Antigravity (OAuth, Gemini Code Assist, SSE) ----
export async function runAntigravityTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const access = requireAccessToken(input.credential, "Google Antigravity");
if (!input.credential.projectId) throw new Error("Antigravity credential is missing its project id.");
const requestId = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const body = antigravityRequestBody({
model: input.model,
projectId: input.credential.projectId,
instructions: input.instructions,
messages: input.messages,
tools: input.tools,
requestId,
});
const response = await postEventStream(`${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, {
Authorization: `Bearer ${access}`,
"User-Agent": `antigravity/${ANTIGRAVITY_VERSION} darwin/arm64`,
}, body);
return accumulateGeminiStream(response.messages);
}
@@ -332,7 +332,8 @@ export class TaskExecutionService {
and a.heartbeat_enabled = 1
and a.lifecycle = 'active'
and p.enabled = 1
and not exists (select 1 from task_runs r where r.task_id = t.id)
and not exists (select 1 from task_runs r where r.task_id = t.id and r.state in ('queued', 'running', 'completed'))
and (select count(*) from task_runs r where r.task_id = t.id) < 3
`).all() as Array<{ task_id: string; org_id: string }>;
const created: string[] = [];
for (const row of rows) {
@@ -0,0 +1,167 @@
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import type { JsonRecord, ProviderEventStreamMessage } from "../../src/main/services/provider-http";
import type { AgentTurnMessage } from "../../src/main/services/provider-tool-turn";
import { accumulateResponsesStream, parseResponsesOutput, toResponsesInput } from "../../src/main/services/provider-tool-turns/responses-format";
import { parseChatChoice, toChatMessages } from "../../src/main/services/provider-tool-turns/chat-format";
import { parseAnthropicContent, toAnthropicMessages } from "../../src/main/services/provider-tool-turns/anthropic-format";
import { accumulateGeminiStream, antigravityRequestBody } from "../../src/main/services/provider-tool-turns/gemini-format";
import { selectToolTurn, supportsToolTurn } from "../../src/main/services/provider-tool-turns/router";
import { resolveTurnCredential } from "../../src/main/services/provider-tool-turns/credential-resolver";
import { DatabaseConnection } from "../../src/main/database/connection";
import { OrganizationService } from "../../src/main/services/organization-service";
import { ProviderService } from "../../src/main/services/provider-service";
import { providerAdapters } from "../../src/main/services/provider-adapters";
import { AuthBrokerService } from "../../src/main/services/auth-broker-service";
import { loadConfiguredProvider } from "../../src/main/services/provider-internal";
const CONVO: AgentTurnMessage[] = [
{ role: "user", text: "do it" },
{ role: "assistant", text: "calling", toolCalls: [{ id: "c1", name: "hire_agent", arguments: { name: "Sam" } }] },
{ role: "tool", results: [{ toolCallId: "c1", toolName: "hire_agent", output: "ok" }] },
];
async function* stream(items: JsonRecord[]): AsyncIterable<ProviderEventStreamMessage> {
for (const data of items) yield { eventName: null, data, rawData: JSON.stringify(data) };
}
const testVault = {
encryptSecret: (secret: string) => Buffer.from(secret, "utf8"),
decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"),
};
describe("openai responses encoding", () => {
test("encodes the conversation and tool calls", () => {
expect(toResponsesInput(CONVO)).toEqual([
{ role: "user", content: [{ type: "input_text", text: "do it" }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "calling", annotations: [] }] },
{ type: "function_call", call_id: "c1", name: "hire_agent", arguments: JSON.stringify({ name: "Sam" }) },
{ type: "function_call_output", call_id: "c1", output: "ok" },
]);
});
test("parses output text and function calls", () => {
const data = { output: [
{ type: "message", content: [{ type: "output_text", text: "done" }] },
{ type: "function_call", call_id: "c2", name: "create_task", arguments: '{"title":"x"}' },
] };
expect(parseResponsesOutput(data)).toEqual({ text: "done", toolCalls: [{ id: "c2", name: "create_task", arguments: { title: "x" } }] });
});
test("accumulates the SSE completion event", async () => {
const result = await accumulateResponsesStream(stream([
{ type: "response.output_text.delta", delta: "partial" },
{ type: "response.completed", response: { output: [{ type: "function_call", call_id: "c3", name: "write_deliverable", arguments: '{"path":"a"}' }] } },
]));
expect(result.toolCalls).toEqual([{ id: "c3", name: "write_deliverable", arguments: { path: "a" } }]);
});
});
describe("openai chat completions encoding", () => {
test("encodes system, tool calls and tool results", () => {
expect(toChatMessages("sys", CONVO)).toEqual([
{ role: "system", content: "sys" },
{ role: "user", content: "do it" },
{ role: "assistant", content: "calling", tool_calls: [{ id: "c1", type: "function", function: { name: "hire_agent", arguments: JSON.stringify({ name: "Sam" }) } }] },
{ role: "tool", tool_call_id: "c1", content: "ok" },
]);
});
test("parses a tool call choice", () => {
const data = { choices: [{ message: { content: "hi", tool_calls: [{ id: "c9", function: { name: "f", arguments: '{"a":1}' } }] } }] };
expect(parseChatChoice(data)).toEqual({ text: "hi", toolCalls: [{ id: "c9", name: "f", arguments: { a: 1 } }] });
});
});
describe("anthropic encoding", () => {
test("encodes tool_use and tool_result", () => {
expect(toAnthropicMessages(CONVO)).toEqual([
{ role: "user", content: [{ type: "text", text: "do it" }] },
{ role: "assistant", content: [{ type: "text", text: "calling" }, { type: "tool_use", id: "c1", name: "hire_agent", input: { name: "Sam" } }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "c1", content: "ok" }] },
]);
});
test("parses text and tool_use blocks", () => {
const data = { content: [{ type: "text", text: "ok" }, { type: "tool_use", id: "tu", name: "f", input: { x: 1 } }] };
expect(parseAnthropicContent(data)).toEqual({ text: "ok", toolCalls: [{ id: "tu", name: "f", arguments: { x: 1 } }] });
});
});
describe("antigravity (gemini) encoding", () => {
test("wraps the request with the Antigravity system instruction and tool declarations", () => {
const body = antigravityRequestBody({ model: "claude-sonnet-4-5", projectId: "proj", instructions: "be helpful", messages: CONVO, tools: [{ name: "f", description: "d", parameters: { type: "object" } }], requestId: "agent-1" });
expect(body.project).toBe("proj");
expect(body.requestType).toBe("agent");
expect(body.userAgent).toBe("antigravity");
const request = body.request as JsonRecord;
const system = request.systemInstruction as { role: string; parts: Array<{ text: string }> };
expect(system.role).toBe("user");
expect(system.parts[0].text).toContain("You are Antigravity");
expect(system.parts[system.parts.length - 1].text).toBe("be helpful");
// claude-* models use the legacy `parameters` field
expect(request.tools).toEqual([{ functionDeclarations: [{ name: "f", description: "d", parameters: { type: "object" } }] }]);
});
test("gemini models use parametersJsonSchema and omit the tool-call id", () => {
const body = antigravityRequestBody({ model: "gemini-3.1-pro-high", projectId: "proj", instructions: null, messages: CONVO, tools: [{ name: "f", description: "d", parameters: { type: "object" } }], requestId: "agent-2" });
const request = body.request as JsonRecord;
expect(request.tools).toEqual([{ functionDeclarations: [{ name: "f", description: "d", parametersJsonSchema: { type: "object" } }] }]);
const contents = request.contents as Array<{ role: string; parts: Array<JsonRecord> }>;
const assistant = contents.find((c) => c.role === "model");
const fnCall = assistant?.parts.find((p) => "functionCall" in p)?.functionCall as JsonRecord;
expect(fnCall.id).toBeUndefined();
});
test("accumulates streamed candidates", async () => {
const result = await accumulateGeminiStream(stream([
{ response: { candidates: [{ content: { parts: [{ text: "hello" }, { functionCall: { name: "f", args: { x: 1 }, id: "g1" } }] } }] } },
]));
expect(result.text).toBe("hello");
expect(result.toolCalls).toEqual([{ id: "g1", name: "f", arguments: { x: 1 } }]);
});
});
describe("tool-turn router", () => {
test("supports every agent-capable provider and rejects cloud-only ones", () => {
for (const key of ["openai", "anthropic", "xai", "groq", "mistral", "cerebras", "openrouter", "gpt-account", "github-copilot", "google-antigravity", "claude-pro"]) {
expect(supportsToolTurn(key)).toBe(true);
expect(typeof selectToolTurn(key)).toBe("function");
}
for (const key of ["azure-openai", "aws-bedrock", "vertex-ai"]) {
expect(supportsToolTurn(key)).toBe(false);
expect(() => selectToolTurn(key)).toThrow();
}
});
});
describe("credential resolver", () => {
test("returns the api key for api-key providers and the access token for fresh OAuth", () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-cred-${randomUUID()}.db`));
const organization = new OrganizationService(connection.db);
const providers = new ProviderService(connection.db, providerAdapters, testVault);
const authBroker = new AuthBrokerService(connection.db, testVault);
let orgId = "";
let openaiId = "";
let gptAccountId = "";
const future = Date.now() + 60 * 60 * 1000;
connection.transaction(() => {
orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Dana", mission: "m", goal: "g" });
openaiId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
gptAccountId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'gpt-account'").get(orgId) as { id: string }).id;
providers.saveCredential({ orgId, providerId: openaiId, secret: "sk-live", model: "gpt-5.4" });
providers.saveCredential({ orgId, providerId: gptAccountId, secret: JSON.stringify({ access: "tok", refresh: "r", expires: future, accountId: "acct_1" }), model: "gpt-5.4" });
});
return (async () => {
const apiKeyCred = await resolveTurnCredential({ authBroker, orgId, provider: loadConfiguredProvider(connection.db, orgId, openaiId) });
expect(apiKeyCred).toEqual({ apiKey: "sk-live" });
const oauthCred = await resolveTurnCredential({ authBroker, orgId, provider: loadConfiguredProvider(connection.db, orgId, gptAccountId) });
expect(oauthCred.accessToken).toBe("tok");
expect(oauthCred.accountId).toBe("acct_1");
connection.close();
})();
});
});