From 4d2425dde01e4db202e676e18d301282ae6756d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 29 May 2026 22:43:42 -0400 Subject: [PATCH] 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. --- .../services/provider-tool-turns/aws-sigv4.ts | 88 +++++++++++++++++++ .../provider-tool-turns/bedrock-format.ts | 46 ++++++++++ .../credential-resolver.ts | 27 +++++- .../services/provider-tool-turns/gcp-auth.ts | 58 ++++++++++++ .../provider-tool-turns/gemini-format.ts | 43 +++++++++ .../services/provider-tool-turns/router.ts | 6 ++ .../services/provider-tool-turns/turns.ts | 61 ++++++++++++- .../tests/unit/provider-tool-turns.test.ts | 56 ++++++++++-- 8 files changed, 377 insertions(+), 8 deletions(-) create mode 100644 AgentCompany-New/src/main/services/provider-tool-turns/aws-sigv4.ts create mode 100644 AgentCompany-New/src/main/services/provider-tool-turns/bedrock-format.ts create mode 100644 AgentCompany-New/src/main/services/provider-tool-turns/gcp-auth.ts diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/aws-sigv4.ts b/AgentCompany-New/src/main/services/provider-tool-turns/aws-sigv4.ts new file mode 100644 index 00000000..792df332 --- /dev/null +++ b/AgentCompany-New/src/main/services/provider-tool-turns/aws-sigv4.ts @@ -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//converse). + */ +export function signAwsRequest(input: { + method: string; + host: string; + rawPath: string; + body: string; + signer: AwsSigner; +}): { url: string; headers: Record } { + 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 = { + 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}`, + }, + }; +} diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/bedrock-format.ts b/AgentCompany-New/src/main/services/provider-tool-turns/bedrock-format.ts new file mode 100644 index 00000000..3a14319e --- /dev/null +++ b/AgentCompany-New/src/main/services/provider-tool-turns/bedrock-format.ts @@ -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 }; +} diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/credential-resolver.ts b/AgentCompany-New/src/main/services/provider-tool-turns/credential-resolver.ts index 8a8416b4..873de525 100644 --- a/AgentCompany-New/src/main/services/provider-tool-turns/credential-resolver.ts +++ b/AgentCompany-New/src/main/services/provider-tool-turns/credential-resolver.ts @@ -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.`); } diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/gcp-auth.ts b/AgentCompany-New/src/main/services/provider-tool-turns/gcp-auth.ts new file mode 100644 index 00000000..5c999371 --- /dev/null +++ b/AgentCompany-New/src/main/services/provider-tool-turns/gcp-auth.ts @@ -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(); + +function base64url(value: string | Buffer): string { + return Buffer.from(value).toString("base64url"); +} + +export async function getGcpAccessToken(serviceAccountJson: string): Promise { + 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; +} diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/gemini-format.ts b/AgentCompany-New/src/main/services/provider-tool-turns/gemini-format.ts index 032ae91d..1bc7424e 100644 --- a/AgentCompany-New/src/main/services/provider-tool-turns/gemini-format.ts +++ b/AgentCompany-New/src/main/services/provider-tool-turns/gemini-format.ts @@ -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): Promise { const raw: string[] = []; const textParts: string[] = []; diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/router.ts b/AgentCompany-New/src/main/services/provider-tool-turns/router.ts index 4c35400e..426d774b 100644 --- a/AgentCompany-New/src/main/services/provider-tool-turns/router.ts +++ b/AgentCompany-New/src/main/services/provider-tool-turns/router.ts @@ -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 = { "claude-pro": runClaudeProTurn, "custom-openai": runCustomOpenAiTurn, "custom-anthropic": runCustomAnthropicTurn, + "azure-openai": runAzureTurn, + "aws-bedrock": runBedrockTurn, + "vertex-ai": runVertexTurn, }; export function supportsToolTurn(providerKey: string): boolean { diff --git a/AgentCompany-New/src/main/services/provider-tool-turns/turns.ts b/AgentCompany-New/src/main/services/provider-tool-turns/turns.ts index 938ba792..f3b342a8 100644 --- a/AgentCompany-New/src/main/services/provider-tool-turns/turns.ts +++ b/AgentCompany-New/src/main/services/provider-tool-turns/turns.ts @@ -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 { + 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 { + 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 { + 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) }; +} diff --git a/AgentCompany-New/tests/unit/provider-tool-turns.test.ts b/AgentCompany-New/tests/unit/provider-tool-turns.test.ts index 6dbc0033..ce482fbd 100644 --- a/AgentCompany-New/tests/unit/provider-tool-turns.test.ts +++ b/AgentCompany-New/tests/unit/provider-tool-turns.test.ts @@ -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(); }); });