@agentforce/adk
Version:
AgentForce Agent Development Kit - A powerful framework for building AI agents and servers
528 lines (447 loc) • 20.2 kB
text/typescript
import type { AgentForceServer } from "../../server";
import type { AgentForceAgent } from "../../agent";
import type { Context } from "hono";
/**
* OpenAI content object for multimodal messages
*/
export interface OpenAIContentObject {
type: "text" | "image_url";
text?: string;
image_url?: {
url: string;
detail?: "auto" | "low" | "high";
};
}
/**
* OpenAI-compatible message format with flexible content
*/
export interface OpenAIMessage {
role: "system" | "user" | "assistant" | "tool";
content: string | OpenAIContentObject[];
name?: string;
tool_calls?: Array<{
id: string;
type: "function";
function: {
name: string;
arguments: string;
};
}>;
tool_call_id?: string;
}
/**
* OpenAI streaming options
*/
export interface OpenAIStreamOptions {
include_usage?: boolean;
}
/**
* OpenAI tool definition
*/
export interface OpenAITool {
type: "function";
function: {
name: string;
description?: string;
parameters?: Record<string, unknown>;
};
}
/**
* OpenAI-compatible chat completion request format with all optional parameters
*/
export interface OpenAIChatCompletionRequest {
model: string;
messages: OpenAIMessage[];
temperature?: number;
max_tokens?: number;
top_p?: number;
n?: number;
stream?: boolean;
stream_options?: OpenAIStreamOptions;
stop?: string | string[];
presence_penalty?: number;
frequency_penalty?: number;
logit_bias?: Record<string, number>;
user?: string;
tools?: OpenAITool[];
tool_choice?: "none" | "auto" | { type: "function"; function: { name: string } };
response_format?: { type: "text" | "json_object" };
seed?: number;
}
/**
* OpenAI-compatible agent route configuration
*/
export interface OpenAICompatibleRouteAgent {
path: string;
agent: AgentForceAgent;
}
/**
* Adds an OpenAI-compatible agent that responds to "/v1/chat/completions" endpoint (chainable method)
* @param this - The AgentForceServer instance (bound context)
* @param agent - The AgentForce agent instance to handle OpenAI-compatible requests
* @returns {AgentForceServer} The server instance for method chaining
*/
export function useOpenAICompatibleRouting(
this: AgentForceServer,
agent: AgentForceAgent,
): AgentForceServer {
if (!agent) {
throw new Error("Agent instance is required");
}
// Validate that the agent has the required methods using bracket notation
if (typeof agent["getName"] !== "function") {
throw new Error("Agent instance is required");
}
const path = "/v1/chat/completions";
const method = "POST";
const log = this.getLogger();
const serverName = this.getName();
log.info({
serverName,
method,
path,
agentName: agent["getName"](),
action: "openai_compatible_agent_added",
}, `Adding OpenAI-compatible agent: ${method} ${path}`);
// Store the OpenAI-compatible route agent configuration
const routeAgent = {
method,
path,
agent,
};
// Add to the server's route agents collection
this.addToRouteAgents(routeAgent);
return this;
}
/**
* Validates OpenAI content object
* @param content - The content to validate
* @param messageIndex - Index of the message for error reporting
* @returns {boolean} True if valid, throws error if invalid
*/
function validateOpenAIContent(content: any, messageIndex: number): boolean {
if (typeof content === "string") {
if (content.length === 0) {
throw new Error(`Message at index ${messageIndex} has empty content string`);
}
return true;
}
if (Array.isArray(content)) {
if (content.length === 0) {
throw new Error(`Message at index ${messageIndex} has empty content array`);
}
for (let j = 0; j < content.length; j++) {
const contentObj = content[j];
if (!contentObj || typeof contentObj !== "object") {
throw new Error(`Message at index ${messageIndex}, content object at index ${j} must be a valid object`);
}
if (!contentObj.type || typeof contentObj.type !== "string") {
throw new Error(`Message at index ${messageIndex}, content object at index ${j} missing "type" field`);
}
if (!["text", "image_url"].includes(contentObj.type)) {
throw new Error(`Message at index ${messageIndex}, content object at index ${j} has invalid type "${contentObj.type}". Must be "text" or "image_url"`);
}
if (contentObj.type === "text" && (!contentObj.text || typeof contentObj.text !== "string")) {
throw new Error(`Message at index ${messageIndex}, content object at index ${j} with type "text" must have a non-empty "text" field`);
}
if (contentObj.type === "image_url") {
if (!contentObj.image_url || typeof contentObj.image_url !== "object") {
throw new Error(`Message at index ${messageIndex}, content object at index ${j} with type "image_url" must have an "image_url" object`);
}
if (!contentObj.image_url.url || typeof contentObj.image_url.url !== "string") {
throw new Error(`Message at index ${messageIndex}, content object at index ${j} with type "image_url" must have a valid "url" in image_url object`);
}
}
}
return true;
}
throw new Error(`Message at index ${messageIndex} content must be either a string or an array of content objects`);
}
/**
* Validates OpenAI chat completion request format
* @param data - The request data to validate
* @returns {boolean} True if valid, throws error if invalid
*/
function validateOpenAIChatCompletionRequest(data: any): data is OpenAIChatCompletionRequest {
if (!data || typeof data !== "object") {
throw new Error("Request body must be a valid JSON object");
}
if (!data.model || typeof data.model !== "string") {
throw new Error("Missing or invalid \"model\" field. Must be a non-empty string");
}
if (!data.messages || !Array.isArray(data.messages)) {
throw new Error("Missing or invalid \"messages\" field. Must be an array");
}
if (data.messages.length === 0) {
throw new Error("Messages array cannot be empty");
}
// Validate each message
for (let i = 0; i < data.messages.length; i++) {
const message = data.messages[i];
if (!message || typeof message !== "object") {
throw new Error(`Message at index ${i} must be a valid object`);
}
if (!message.role || typeof message.role !== "string") {
throw new Error(`Message at index ${i} missing or invalid "role" field`);
}
if (!["system", "user", "assistant", "tool"].includes(message.role)) {
throw new Error(`Message at index ${i} has invalid role "${message.role}". Must be "system", "user", "assistant", or "tool"`);
}
if (message.content === undefined || message.content === null) {
throw new Error(`Message at index ${i} missing "content" field`);
}
validateOpenAIContent(message.content, i);
// Optional field validations
if (message.name !== undefined && typeof message.name !== "string") {
throw new Error(`Message at index ${i} has invalid "name" field. Must be a string if provided`);
}
if (message.tool_call_id !== undefined && typeof message.tool_call_id !== "string") {
throw new Error(`Message at index ${i} has invalid "tool_call_id" field. Must be a string if provided`);
}
if (message.tool_calls !== undefined) {
if (!Array.isArray(message.tool_calls)) {
throw new Error(`Message at index ${i} has invalid "tool_calls" field. Must be an array if provided`);
}
// Additional tool_calls validation could be added here
}
}
// Validate optional parameters with type checking
if (data.temperature !== undefined && (typeof data.temperature !== "number" || data.temperature < 0 || data.temperature > 2)) {
throw new Error("Invalid \"temperature\" field. Must be a number between 0 and 2 if provided");
}
if (data.max_tokens !== undefined && (typeof data.max_tokens !== "number" || data.max_tokens < 1)) {
throw new Error("Invalid \"max_tokens\" field. Must be a positive number if provided");
}
if (data.top_p !== undefined && (typeof data.top_p !== "number" || data.top_p < 0 || data.top_p > 1)) {
throw new Error("Invalid \"top_p\" field. Must be a number between 0 and 1 if provided");
}
if (data.n !== undefined && (typeof data.n !== "number" || data.n < 1 || data.n > 128)) {
throw new Error("Invalid \"n\" field. Must be a number between 1 and 128 if provided");
}
if (data.stream !== undefined && typeof data.stream !== "boolean") {
throw new Error("Invalid \"stream\" field. Must be a boolean if provided");
}
if (data.presence_penalty !== undefined && (typeof data.presence_penalty !== "number" || data.presence_penalty < -2 || data.presence_penalty > 2)) {
throw new Error("Invalid \"presence_penalty\" field. Must be a number between -2 and 2 if provided");
}
if (data.frequency_penalty !== undefined && (typeof data.frequency_penalty !== "number" || data.frequency_penalty < -2 || data.frequency_penalty > 2)) {
throw new Error("Invalid \"frequency_penalty\" field. Must be a number between -2 and 2 if provided");
}
if (data.user !== undefined && typeof data.user !== "string") {
throw new Error("Invalid \"user\" field. Must be a string if provided");
}
return true;
}
/**
* Extracts text content from OpenAI message content (string or array)
* @param content - The content field from an OpenAI message
* @returns {string} The extracted text content
*/
function extractTextContent(content: string | OpenAIContentObject[]): string {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
// Extract text from content objects
const textParts: string[] = [];
for (const contentObj of content) {
if (contentObj.type === "text" && contentObj.text) {
textParts.push(contentObj.text);
} else if (contentObj.type === "image_url") {
// For image content, add a placeholder or description
textParts.push("[Image provided]");
}
}
return textParts.join(" ");
}
return "";
}
/**
* Converts OpenAI messages format to a conversation context string
* @param messages - Array of OpenAI messages
* @returns {string} The full conversation formatted for the model
*/
function formatConversationContext(messages: OpenAIMessage[]): string {
if (messages.length === 0) {
throw new Error("Messages array cannot be empty");
}
// Find the last user message for validation
const userMessages = messages.filter(msg => msg.role === "user");
if (userMessages.length === 0) {
throw new Error("No user message found in messages array");
}
// If only one user message and no assistant messages, return just the user content
if (messages.length === 1 && messages[0]?.role === "user") {
return extractTextContent(messages[0].content);
}
// Format the full conversation for context
const conversationLines: string[] = [];
for (const message of messages) {
const textContent = extractTextContent(message.content);
switch (message.role) {
case "system":
conversationLines.push(`System: ${textContent}`);
break;
case "user":
conversationLines.push(`Human: ${textContent}`);
break;
case "assistant":
conversationLines.push(`Assistant: ${textContent}`);
break;
case "tool":
conversationLines.push(`Tool: ${textContent}`);
break;
}
}
// Add the current context instruction
conversationLines.push("\nPlease respond as the Assistant, taking into account the full conversation history above.");
return conversationLines.join("\n");
}
/**
* Parses OpenAI model parameter to extract provider and model
* @param modelParam - The model parameter from OpenAI request (e.g., "ollama/gemma3:12b")
* @returns {object} Object containing provider and model
*/
function parseModelParameter(modelParam: string): { provider: string; model: string } {
if (!modelParam || typeof modelParam !== "string") {
throw new Error("Model parameter must be a non-empty string");
}
// Check if model contains provider separator "/"
if (modelParam.includes("/")) {
const parts = modelParam.split("/", 2);
const provider = parts[0]?.trim();
const model = parts[1]?.trim();
if (!provider || !model) {
throw new Error("Invalid model format. Expected 'provider/model' (e.g., 'ollama/gemma3:12b')");
}
return { provider, model };
}
// If no provider specified, assume it's just a model name and use default provider
return { provider: "ollama", model: modelParam.trim() };
}
/**
* Creates a Hono route handler for OpenAI-compatible endpoints
* @param agent - The AgentForce agent to handle the request
* @param path - Route path for logging purposes
* @returns Hono route handler function
*/
export function createOpenAICompatibleRouteHandler(agent: AgentForceAgent, path: string): (c: Context) => Promise<Response> {
return async (c: Context): Promise<Response> => {
try {
let requestData: Record<string, unknown> = {};
// 🔍 DEBUG: Log incoming request details
console.log("=== OPENAI COMPATIBLE REQUEST DEBUG ===");
console.log("Path:", path);
console.log("Request URL:", c.req.url);
console.log("Request Headers:", c.req.header());
try {
requestData = await c.req.json();
// 🔍 DEBUG: Log the parsed request body
console.log("📝 Request Body (parsed JSON):", JSON.stringify(requestData, null, 2));
console.log("Request Body Type:", typeof requestData);
console.log("Request Body Keys:", Object.keys(requestData || {}));
} catch (jsonError) {
console.log("❌ JSON Parse Error:", jsonError);
return c.json({
error: "Invalid JSON in request body",
message: "Please provide valid JSON data",
}, 400);
}
console.log("🔍 OpenAI Endpoint - Validating Request...");
console.log("Request Data for Validation:", JSON.stringify(requestData, null, 2));
try {
validateOpenAIChatCompletionRequest(requestData);
console.log("✅ OpenAI Request Validation Passed");
const openAIRequest = requestData as unknown as OpenAIChatCompletionRequest;
console.log("📧 OpenAI Messages:", JSON.stringify(openAIRequest.messages, null, 2));
console.log("🤖 OpenAI Model:", openAIRequest.model);
const prompt = formatConversationContext(openAIRequest.messages);
console.log("💬 Formatted Conversation Context:", prompt);
// Parse and set provider/model from the request
try {
const { provider, model } = parseModelParameter(openAIRequest.model);
agent["setProvider"](provider);
agent["setModel"](model);
} catch (parseError) {
console.log("❌ Model Parameter Parse Error:", parseError);
console.log("Failed Model Parameter:", openAIRequest.model);
return c.json({
error: "Invalid model parameter",
message: parseError instanceof Error ? parseError.message : "Unable to parse model parameter",
example: {
model: "ollama/gemma3:12b",
messages: [
{
role: "user",
content: "what llm are you",
},
],
},
}, 400);
}
// Execute the agent with the extracted prompt
let response: string;
try {
console.log("🚀 Executing Agent with Prompt:", prompt);
response = await agent
.prompt(prompt)
.getResponse();
console.log("✅ Agent Response Received:", response.substring(0, 100) + (response.length > 100 ? "..." : ""));
} catch (error) {
console.error("❌ Error executing agent:", error);
return c.json({
error: "Agent execution failed",
message: error instanceof Error ? error.message : "Unknown error occurred",
}, 500);
}
// Return OpenAI-compatible response
console.log("📤 Returning OpenAI-compatible response");
const openAIResponse = {
id: `chatcmpl-${Date.now()}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: openAIRequest.model,
choices: [
{
index: 0,
message: {
role: "assistant",
content: response,
},
finish_reason: "stop",
},
],
usage: {
prompt_tokens: prompt.length / 4, // Rough estimate
completion_tokens: response.length / 4, // Rough estimate
total_tokens: (prompt.length + response.length) / 4,
},
};
console.log("Response JSON:", JSON.stringify(openAIResponse, null, 2));
return c.json(openAIResponse);
} catch (error) {
console.log("❌ OpenAI Request Validation Failed:", error);
console.log("Failed Request Data:", JSON.stringify(requestData, null, 2));
return c.json({
error: "Invalid OpenAI chat completion format",
message: error instanceof Error ? error.message : "Unknown validation error",
example: {
model: "ollama/gemma3:12b",
messages: [
{
role: "user",
content: "what llm are you",
},
],
},
}, 400);
}
} catch (error) {
console.error(`Error in OpenAI-compatible route ${path}:`, error);
return c.json({
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error occurred",
}, 500);
}
};
}