Run the agent loop on Azure, Bedrock and Vertex too
Every catalog provider can now drive agent execution: - Azure OpenAI -> Chat Completions at the deployment URL with api-key - AWS Bedrock -> Converse API signed natively with SigV4 (node:crypto) - Vertex AI -> Gemini generateContent with a service-account JWT (RS256) exchanged for a cached access token The credential resolver maps each cloud profile to the turn's needs; the router covers all 16 providers. Pure encoders, the SigV4 signer and the GCP auth are unit-tested; no AWS/GCP SDK dependency.
This commit is contained in:
1 parent
6c68836455
commit
4d2425dde0
8 files changed
+377
-8
No files matched your search
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* AWS Signature Version 4 signing for Bedrock Runtime requests. Implements the
|
||||
* canonical-request / string-to-sign / signing-key chain with node:crypto so no
|
||||
* AWS SDK is needed.
|
||||
*/
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
|
||||
export interface AwsSigner {
|
||||
region: string;
|
||||
service: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
sessionToken?: string | null;
|
||||
}
|
||||
|
||||
function sha256Hex(data: string): string {
|
||||
return createHash("sha256").update(data, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function hmac(key: Buffer | string, data: string): Buffer {
|
||||
return createHmac("sha256", typeof key === "string" ? Buffer.from(key, "utf8") : key).update(data, "utf8").digest();
|
||||
}
|
||||
|
||||
/** RFC 3986 encoding used by SigV4; keeps "/" only when encodeSlash is false. */
|
||||
function awsUriEncode(value: string, encodeSlash: boolean): string {
|
||||
let out = "";
|
||||
for (const ch of value) {
|
||||
if (/[A-Za-z0-9\-._~]/.test(ch)) out += ch;
|
||||
else if (ch === "/" && !encodeSlash) out += ch;
|
||||
else out += [...Buffer.from(ch, "utf8")].map((byte) => `%${byte.toString(16).toUpperCase().padStart(2, "0")}`).join("");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function amzTimestamp(): { amzDate: string; dateStamp: string } {
|
||||
const iso = new Date().toISOString().replace(/[:-]|\.\d{3}/g, "");
|
||||
return { amzDate: iso, dateStamp: iso.slice(0, 8) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs a request and returns the absolute (encoded) URL plus the headers to
|
||||
* send. `rawPath` is the unencoded absolute path (e.g. /model/<modelId>/converse).
|
||||
*/
|
||||
export function signAwsRequest(input: {
|
||||
method: string;
|
||||
host: string;
|
||||
rawPath: string;
|
||||
body: string;
|
||||
signer: AwsSigner;
|
||||
}): { url: string; headers: Record<string, string> } {
|
||||
const { host, rawPath, body, signer } = input;
|
||||
const { region, service, accessKeyId, secretAccessKey, sessionToken } = signer;
|
||||
const { amzDate, dateStamp } = amzTimestamp();
|
||||
const canonicalUri = awsUriEncode(rawPath, false);
|
||||
const payloadHash = sha256Hex(body);
|
||||
|
||||
const baseHeaders: Record<string, string> = {
|
||||
host,
|
||||
"x-amz-content-sha256": payloadHash,
|
||||
"x-amz-date": amzDate,
|
||||
};
|
||||
if (sessionToken) baseHeaders["x-amz-security-token"] = sessionToken;
|
||||
|
||||
const sortedHeaderKeys = Object.keys(baseHeaders).sort();
|
||||
const canonicalHeaders = sortedHeaderKeys.map((key) => `${key}:${baseHeaders[key].trim()}\n`).join("");
|
||||
const signedHeaders = sortedHeaderKeys.join(";");
|
||||
const canonicalRequest = [input.method, canonicalUri, "", canonicalHeaders, signedHeaders, payloadHash].join("\n");
|
||||
|
||||
const scope = `${dateStamp}/${region}/${service}/aws4_request`;
|
||||
const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256Hex(canonicalRequest)].join("\n");
|
||||
|
||||
const kDate = hmac(`AWS4${secretAccessKey}`, dateStamp);
|
||||
const kRegion = hmac(kDate, region);
|
||||
const kService = hmac(kRegion, service);
|
||||
const kSigning = hmac(kService, "aws4_request");
|
||||
const signature = createHmac("sha256", kSigning).update(stringToSign, "utf8").digest("hex");
|
||||
|
||||
// `host` is signed but not returned: fetch sets the Host header from the URL.
|
||||
return {
|
||||
url: `https://${host}${canonicalUri}`,
|
||||
headers: {
|
||||
"x-amz-content-sha256": payloadHash,
|
||||
"x-amz-date": amzDate,
|
||||
...(sessionToken ? { "x-amz-security-token": sessionToken } : {}),
|
||||
Authorization: `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/** AWS Bedrock Converse API encoding/decoding (ported from Pi's bedrock handler). */
|
||||
import { isRecord, type JsonRecord } from "../provider-http";
|
||||
import type { AgentTurnMessage, ToolCall, ToolDefinition } from "../provider-tool-turn";
|
||||
|
||||
export function bedrockToolConfig(tools: ToolDefinition[]): JsonRecord {
|
||||
return {
|
||||
tools: tools.map((tool) => ({ toolSpec: { name: tool.name, description: tool.description, inputSchema: { json: tool.parameters } } })),
|
||||
toolChoice: { auto: {} },
|
||||
};
|
||||
}
|
||||
|
||||
export function toBedrockMessages(messages: AgentTurnMessage[]): JsonRecord[] {
|
||||
const out: JsonRecord[] = [];
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
out.push({ role: "user", content: [{ text: message.text }] });
|
||||
} else if (message.role === "assistant") {
|
||||
const content: unknown[] = [];
|
||||
if (message.text) content.push({ text: message.text });
|
||||
for (const call of message.toolCalls) content.push({ toolUse: { toolUseId: call.id, name: call.name, input: call.arguments } });
|
||||
out.push({ role: "assistant", content });
|
||||
} else {
|
||||
out.push({ role: "user", content: message.results.map((result) => ({ toolResult: { toolUseId: result.toolCallId, content: [{ text: result.output }] } })) });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseBedrockOutput(data: JsonRecord): { text: string; toolCalls: ToolCall[] } {
|
||||
const output = isRecord(data.output) ? data.output : null;
|
||||
const message = output && isRecord(output.message) ? output.message : null;
|
||||
const content = message && Array.isArray(message.content) ? message.content : [];
|
||||
const textParts: string[] = [];
|
||||
const toolCalls: ToolCall[] = [];
|
||||
for (const block of content) {
|
||||
if (!isRecord(block)) continue;
|
||||
if (typeof block.text === "string") textParts.push(block.text);
|
||||
else if (isRecord(block.toolUse)) {
|
||||
const toolUse = block.toolUse;
|
||||
const id = typeof toolUse.toolUseId === "string" ? toolUse.toolUseId : "";
|
||||
const name = typeof toolUse.name === "string" ? toolUse.name : "";
|
||||
if (id && name) toolCalls.push({ id, name, arguments: isRecord(toolUse.input) ? toolUse.input : {} });
|
||||
}
|
||||
}
|
||||
return { text: textParts.join("\n").trim(), toolCalls };
|
||||
}
|
||||
@@ -8,7 +8,20 @@
|
||||
import { PROVIDER_AUTH_TYPE } from "../../../shared/domain";
|
||||
import type { AuthBrokerService } from "../auth-broker-service";
|
||||
import { loadCredential, type ProviderRow } from "../provider-internal";
|
||||
import { parseAnthropicCompatProfile, parseApiKey, parseOpenAiCompatProfile, requireAnthropicCompatProfile, requireApiKeyProfile, requireOpenAiCompatProfile } from "../provider-auth-profiles";
|
||||
import {
|
||||
parseAnthropicCompatProfile,
|
||||
parseApiKey,
|
||||
parseAzureProfile,
|
||||
parseBedrockProfile,
|
||||
parseOpenAiCompatProfile,
|
||||
parseVertexProfile,
|
||||
requireAnthropicCompatProfile,
|
||||
requireApiKeyProfile,
|
||||
requireAzureProfile,
|
||||
requireBedrockProfile,
|
||||
requireOpenAiCompatProfile,
|
||||
requireVertexProfile,
|
||||
} 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";
|
||||
@@ -65,6 +78,18 @@ function resolveCloudCredential(providerKey: string, secret: string): TurnCreden
|
||||
const profile = requireAnthropicCompatProfile(parseAnthropicCompatProfile(secret));
|
||||
return { apiKey: profile.apiKey, baseUrl: profile.baseUrl };
|
||||
}
|
||||
case "azure-openai": {
|
||||
const profile = requireAzureProfile(parseAzureProfile(secret));
|
||||
return { apiKey: profile.apiKey, baseUrl: profile.baseUrl, deployment: profile.deployment };
|
||||
}
|
||||
case "aws-bedrock": {
|
||||
const profile = requireBedrockProfile(parseBedrockProfile(secret));
|
||||
return { region: profile.region, accessKeyId: profile.accessKeyId, secretAccessKey: profile.secretAccessKey, sessionToken: profile.sessionToken };
|
||||
}
|
||||
case "vertex-ai": {
|
||||
const profile = requireVertexProfile(parseVertexProfile(secret));
|
||||
return { projectId: profile.projectId, region: profile.region, serviceAccountJson: profile.serviceAccountJson };
|
||||
}
|
||||
default:
|
||||
throw new Error(`${providerKey} does not support agent execution.`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Mints a Google Cloud access token from a service-account JSON by signing a
|
||||
* JWT (RS256) and exchanging it at the OAuth token endpoint. Tokens are cached
|
||||
* per service account until shortly before expiry.
|
||||
*/
|
||||
import { createSign } from "node:crypto";
|
||||
|
||||
interface ServiceAccount {
|
||||
client_email: string;
|
||||
private_key: string;
|
||||
}
|
||||
|
||||
const TOKEN_CACHE = new Map<string, { token: string; expiresAt: number }>();
|
||||
|
||||
function base64url(value: string | Buffer): string {
|
||||
return Buffer.from(value).toString("base64url");
|
||||
}
|
||||
|
||||
export async function getGcpAccessToken(serviceAccountJson: string): Promise<string> {
|
||||
let account: ServiceAccount;
|
||||
try {
|
||||
account = JSON.parse(serviceAccountJson) as ServiceAccount;
|
||||
} catch {
|
||||
throw new Error("Vertex service-account credential is not valid JSON.");
|
||||
}
|
||||
if (typeof account.client_email !== "string" || typeof account.private_key !== "string") {
|
||||
throw new Error("Vertex service-account JSON is missing client_email or private_key.");
|
||||
}
|
||||
const cached = TOKEN_CACHE.get(account.client_email);
|
||||
if (cached && Date.now() < cached.expiresAt) return cached.token;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
||||
const claims = base64url(JSON.stringify({
|
||||
iss: account.client_email,
|
||||
scope: "https://www.googleapis.com/auth/cloud-platform",
|
||||
aud: "https://oauth2.googleapis.com/token",
|
||||
iat: now,
|
||||
exp: now + 3600,
|
||||
}));
|
||||
const signingInput = `${header}.${claims}`;
|
||||
const signature = base64url(createSign("RSA-SHA256").update(signingInput).sign(account.private_key));
|
||||
const assertion = `${signingInput}.${signature}`;
|
||||
|
||||
const response = await fetch("https://oauth2.googleapis.com/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion }),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) throw new Error(`Vertex authentication failed: ${response.status} ${text}`);
|
||||
const json = JSON.parse(text) as { access_token?: unknown; expires_in?: unknown };
|
||||
if (typeof json.access_token !== "string" || typeof json.expires_in !== "number") {
|
||||
throw new Error("Vertex authentication response was missing an access token.");
|
||||
}
|
||||
TOKEN_CACHE.set(account.client_email, { token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000 - 60 * 1000 });
|
||||
return json.access_token;
|
||||
}
|
||||
@@ -84,6 +84,49 @@ export function antigravityRequestBody(input: {
|
||||
return { project: input.projectId, model: input.model, request, requestType: "agent", userAgent: "antigravity", requestId: input.requestId };
|
||||
}
|
||||
|
||||
/** The Vertex AI generateContent body (Gemini, no Antigravity wrapper). */
|
||||
export function vertexRequestBody(input: {
|
||||
model: string;
|
||||
instructions: string | null;
|
||||
messages: AgentTurnMessage[];
|
||||
tools: ToolDefinition[];
|
||||
}): JsonRecord {
|
||||
const body: JsonRecord = { contents: toGeminiContents(input.model, input.messages) };
|
||||
if (input.instructions) body.systemInstruction = { parts: [{ text: input.instructions }] };
|
||||
if (input.tools.length > 0) {
|
||||
body.tools = geminiToolDefs(input.model, input.tools);
|
||||
body.toolConfig = { functionCallingConfig: { mode: "AUTO" } };
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function partsToResult(parts: unknown[]): { text: string[]; toolCalls: ToolCall[] } {
|
||||
const text: string[] = [];
|
||||
const toolCalls: ToolCall[] = [];
|
||||
let counter = 0;
|
||||
for (const part of parts) {
|
||||
if (!isRecord(part)) continue;
|
||||
if (typeof part.text === "string" && part.thought !== true) text.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, toolCalls };
|
||||
}
|
||||
|
||||
/** Parse a non-streaming Gemini generateContent response (Vertex). */
|
||||
export function parseGeminiResponse(data: JsonRecord): { text: string; toolCalls: ToolCall[] } {
|
||||
const candidates = Array.isArray(data.candidates) ? data.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 : [];
|
||||
const result = partsToResult(parts);
|
||||
return { text: result.text.join("").trim(), toolCalls: result.toolCalls };
|
||||
}
|
||||
|
||||
export async function accumulateGeminiStream(messages: AsyncIterable<ProviderEventStreamMessage>): Promise<AgentTurnResult> {
|
||||
const raw: string[] = [];
|
||||
const textParts: string[] = [];
|
||||
|
||||
@@ -3,12 +3,15 @@ import {
|
||||
makeChatCompletionsTurn,
|
||||
runAnthropicTurn,
|
||||
runAntigravityTurn,
|
||||
runAzureTurn,
|
||||
runBedrockTurn,
|
||||
runClaudeProTurn,
|
||||
runCodexTurn,
|
||||
runCopilotTurn,
|
||||
runCustomAnthropicTurn,
|
||||
runCustomOpenAiTurn,
|
||||
runOpenAiResponsesTurn,
|
||||
runVertexTurn,
|
||||
} from "./turns";
|
||||
|
||||
/** Provider key -> the tool-use turn function that drives the agent loop. */
|
||||
@@ -26,6 +29,9 @@ const TURNS: Record<string, ToolTurnFn> = {
|
||||
"claude-pro": runClaudeProTurn,
|
||||
"custom-openai": runCustomOpenAiTurn,
|
||||
"custom-anthropic": runCustomAnthropicTurn,
|
||||
"azure-openai": runAzureTurn,
|
||||
"aws-bedrock": runBedrockTurn,
|
||||
"vertex-ai": runVertexTurn,
|
||||
};
|
||||
|
||||
export function supportsToolTurn(providerKey: string): boolean {
|
||||
|
||||
@@ -9,7 +9,10 @@ import type { AgentTurnMessage, AgentTurnResult, ProviderToolTurnInput, TurnCred
|
||||
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 { accumulateGeminiStream, antigravityRequestBody, parseGeminiResponse, vertexRequestBody } from "./gemini-format";
|
||||
import { bedrockToolConfig, parseBedrockOutput, toBedrockMessages } from "./bedrock-format";
|
||||
import { signAwsRequest } from "./aws-sigv4";
|
||||
import { getGcpAccessToken } from "./gcp-auth";
|
||||
import { COPILOT_HEADERS, copilotBaseUrl } from "../oauth/github-copilot-flow";
|
||||
|
||||
const ANTIGRAVITY_VERSION = "1.21.9";
|
||||
@@ -206,3 +209,59 @@ export async function runAntigravityTurn(input: ProviderToolTurnInput): Promise<
|
||||
}, body);
|
||||
return accumulateGeminiStream(response.messages);
|
||||
}
|
||||
|
||||
// ---- Azure OpenAI (Chat Completions at the resource deployment) ----
|
||||
|
||||
export async function runAzureTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
const apiKey = requireApiKey(input.credential, "Azure OpenAI");
|
||||
if (!input.credential.baseUrl) throw new Error("Azure OpenAI credential is missing its base URL.");
|
||||
if (!input.credential.deployment) throw new Error("Azure OpenAI credential is missing its deployment name.");
|
||||
const body: JsonRecord = {
|
||||
model: input.credential.deployment,
|
||||
messages: toChatMessages(input.instructions, input.messages),
|
||||
tools: chatToolDefs(input.tools),
|
||||
tool_choice: "auto",
|
||||
stream: false,
|
||||
};
|
||||
const url = `${input.credential.baseUrl}/openai/deployments/${input.credential.deployment}/chat/completions?api-version=2024-10-21`;
|
||||
const response = await postJson(url, { "api-key": apiKey }, body);
|
||||
const parsed = parseChatChoice(response.data);
|
||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
|
||||
}
|
||||
|
||||
// ---- AWS Bedrock (Converse API, SigV4) ----
|
||||
|
||||
export async function runBedrockTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
const { region, accessKeyId, secretAccessKey, sessionToken } = input.credential;
|
||||
if (!region || !accessKeyId || !secretAccessKey) throw new Error("AWS Bedrock credential is incomplete.");
|
||||
const converse: JsonRecord = {
|
||||
messages: toBedrockMessages(input.messages),
|
||||
inferenceConfig: { maxTokens: 4096 },
|
||||
toolConfig: bedrockToolConfig(input.tools),
|
||||
...(input.instructions ? { system: [{ text: input.instructions }] } : {}),
|
||||
};
|
||||
const bodyString = JSON.stringify(converse);
|
||||
const signed = signAwsRequest({
|
||||
method: "POST",
|
||||
host: `bedrock-runtime.${region}.amazonaws.com`,
|
||||
rawPath: `/model/${input.model}/converse`,
|
||||
body: bodyString,
|
||||
signer: { region, service: "bedrock", accessKeyId, secretAccessKey, sessionToken },
|
||||
});
|
||||
const response = await postJson(signed.url, signed.headers, converse);
|
||||
const parsed = parseBedrockOutput(response.data);
|
||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
|
||||
}
|
||||
|
||||
// ---- Google Vertex AI (Gemini generateContent, service-account auth) ----
|
||||
|
||||
export async function runVertexTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
|
||||
const { projectId, region, serviceAccountJson } = input.credential;
|
||||
if (!projectId || !region || !serviceAccountJson) throw new Error("Vertex AI credential is incomplete.");
|
||||
const token = await getGcpAccessToken(serviceAccountJson);
|
||||
const body = vertexRequestBody({ model: input.model, instructions: input.instructions, messages: input.messages, tools: input.tools });
|
||||
const url = `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${input.model}:generateContent`;
|
||||
const response = await postJson(url, { authorization: `Bearer ${token}` }, body);
|
||||
const parsed = parseGeminiResponse(response.data);
|
||||
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import { accumulateResponsesStream, parseResponsesOutput, toResponsesInput } fro
|
||||
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 { bedrockToolConfig, parseBedrockOutput, toBedrockMessages } from "../../src/main/services/provider-tool-turns/bedrock-format";
|
||||
import { parseGeminiResponse, vertexRequestBody } from "../../src/main/services/provider-tool-turns/gemini-format";
|
||||
import { signAwsRequest } from "../../src/main/services/provider-tool-turns/aws-sigv4";
|
||||
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";
|
||||
@@ -124,16 +127,57 @@ describe("antigravity (gemini) encoding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("bedrock converse encoding", () => {
|
||||
test("encodes toolUse/toolResult and the tool config", () => {
|
||||
expect(toBedrockMessages(CONVO)).toEqual([
|
||||
{ role: "user", content: [{ text: "do it" }] },
|
||||
{ role: "assistant", content: [{ text: "calling" }, { toolUse: { toolUseId: "c1", name: "hire_agent", input: { name: "Sam" } } }] },
|
||||
{ role: "user", content: [{ toolResult: { toolUseId: "c1", content: [{ text: "ok" }] } }] },
|
||||
]);
|
||||
expect(bedrockToolConfig([{ name: "f", description: "d", parameters: { type: "object" } }])).toEqual({
|
||||
tools: [{ toolSpec: { name: "f", description: "d", inputSchema: { json: { type: "object" } } } }],
|
||||
toolChoice: { auto: {} },
|
||||
});
|
||||
});
|
||||
|
||||
test("parses the converse output", () => {
|
||||
const data = { output: { message: { content: [{ text: "ok" }, { toolUse: { toolUseId: "t1", name: "f", input: { x: 1 } } }] } } };
|
||||
expect(parseBedrockOutput(data)).toEqual({ text: "ok", toolCalls: [{ id: "t1", name: "f", arguments: { x: 1 } }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("vertex (gemini) encoding", () => {
|
||||
test("builds a generateContent body with tools and parses the response", () => {
|
||||
const body = vertexRequestBody({ model: "gemini-2.5-pro", instructions: "sys", messages: CONVO, tools: [{ name: "f", description: "d", parameters: { type: "object" } }] });
|
||||
expect((body.systemInstruction as JsonRecord).parts).toEqual([{ text: "sys" }]);
|
||||
expect(body.tools).toEqual([{ functionDeclarations: [{ name: "f", description: "d", parametersJsonSchema: { type: "object" } }] }]);
|
||||
const parsed = parseGeminiResponse({ candidates: [{ content: { parts: [{ text: "hi" }, { functionCall: { name: "f", args: { x: 1 }, id: "v1" } }] } }] });
|
||||
expect(parsed).toEqual({ text: "hi", toolCalls: [{ id: "v1", name: "f", arguments: { x: 1 } }] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("aws sigv4", () => {
|
||||
test("encodes the model path and emits a SigV4 authorization header", () => {
|
||||
const signed = signAwsRequest({
|
||||
method: "POST",
|
||||
host: "bedrock-runtime.us-east-1.amazonaws.com",
|
||||
rawPath: "/model/anthropic.claude-sonnet-4-5-v1:0/converse",
|
||||
body: "{}",
|
||||
signer: { region: "us-east-1", service: "bedrock", accessKeyId: "AKIA", secretAccessKey: "secret" },
|
||||
});
|
||||
expect(signed.url).toContain("/model/anthropic.claude-sonnet-4-5-v1%3A0/converse");
|
||||
expect(signed.headers.Authorization).toMatch(/^AWS4-HMAC-SHA256 Credential=AKIA\/\d{8}\/us-east-1\/bedrock\/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=[0-9a-f]{64}$/);
|
||||
});
|
||||
});
|
||||
|
||||
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", "custom-openai", "custom-anthropic"]) {
|
||||
test("supports every agent-capable provider (incl. cloud and custom) and rejects unknown ones", () => {
|
||||
for (const key of ["openai", "anthropic", "xai", "groq", "mistral", "cerebras", "openrouter", "gpt-account", "github-copilot", "google-antigravity", "claude-pro", "custom-openai", "custom-anthropic", "azure-openai", "aws-bedrock", "vertex-ai"]) {
|
||||
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();
|
||||
}
|
||||
expect(supportsToolTurn("not-a-provider")).toBe(false);
|
||||
expect(() => selectToolTurn("not-a-provider")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user