diff --git a/src/providers/ai-sdk-types.ts b/src/providers/ai-sdk-types.ts new file mode 100644 index 0000000..580008c --- /dev/null +++ b/src/providers/ai-sdk-types.ts @@ -0,0 +1,90 @@ +/** + * Type definitions for AI SDK provider + */ + +import { LanguageModel, JSONValue } from 'ai'; + +export interface AiSdkFunctionTool { + type: 'function'; + function: { + name: string; + description?: string; + parameters: Record; + }; +} + +export interface ToolCallFunction { + name: string; + arguments: string | Record; +} + +export interface ToolCall { + id: string; + type: 'function'; + function: ToolCallFunction; +} + +export type AiSdkChatMessageParam = + | { role: 'system'; content: string } + | { + role: 'user' | 'assistant' | 'tool'; + content: string | null; + tool_calls?: ToolCall[]; + tool_call_id?: string; + }; + +export interface AiSdkChatRequest { + model: string; + messages: AiSdkChatMessageParam[]; + temperature?: number; + max_tokens?: number; + maxTokens?: number; + tools?: AiSdkFunctionTool[]; + tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } }; + response_format?: { type: 'json_object' }; + [key: string]: unknown; +} + +export interface Usage { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; +} + +export interface AiSdkChatResponse { + message?: { + content?: string | null; + tool_calls?: ToolCall[]; + }; + choices?: Array<{ + message?: { + content?: string | null; + tool_calls?: ToolCall[]; + }; + }>; + text?: string | null; + id?: string; + model?: string; + created?: number; + usage?: Usage; + [key: string]: unknown; +} + +export interface AiSdkClient { + chat: (request: AiSdkChatRequest) => Promise; +} + +export interface GenerateObjectResult { + object: unknown; +} + +export interface GenerateObjectOptions { + model: LanguageModel; + schema: unknown; + system?: string; + messages: unknown[]; + temperature?: number; + maxOutputTokens?: number; +} + +export type SafeJsonParseResult = JSONValue; \ No newline at end of file diff --git a/src/providers/ai-sdk.ts b/src/providers/ai-sdk.ts index f371223..52082be 100644 --- a/src/providers/ai-sdk.ts +++ b/src/providers/ai-sdk.ts @@ -13,17 +13,18 @@ import { zodSchema, } from 'ai'; import { ModelProvider, Message, getTextContent } from '../core/types.js'; - -export type AiSdkFunctionTool = { - type: 'function'; - function: { - name: string; - description?: string; - parameters: unknown; - }; -}; - -function safeParseJson(text: string): JSONValue { +import { + AiSdkFunctionTool, + AiSdkChatMessageParam, + AiSdkChatRequest, + AiSdkChatResponse, + AiSdkClient, + SafeJsonParseResult, + GenerateObjectResult, + GenerateObjectOptions +} from './ai-sdk-types.js'; + +function safeParseJson(text: string): SafeJsonParseResult { try { return JSON.parse(text) as JSONValue; } catch { @@ -31,87 +32,18 @@ function safeParseJson(text: string): JSONValue { } } -export type AiSdkChatMessageParam = - | { role: 'system'; content: string } - | { - role: 'user' | 'assistant' | 'tool'; - content: string | null; - tool_calls?: Array<{ - id: string; - type: 'function'; - function: { - name: string; - arguments: string | any; - }; - }>; - tool_call_id?: string; - }; - -export type AiSdkChatRequest = { - model: string; - messages: AiSdkChatMessageParam[]; - temperature?: number; - // Support both OpenAI-style and AI SDK-style naming for token limits - max_tokens?: number; - maxTokens?: number; - tools?: AiSdkFunctionTool[]; - tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } }; - response_format?: { type: 'json_object' }; - // Allow arbitrary provider-specific fields - [key: string]: unknown; +export { + AiSdkFunctionTool, + AiSdkChatMessageParam, + AiSdkChatRequest, + AiSdkChatResponse, + AiSdkClient }; -export type AiSdkChatResponse = { - // Prefer a single normalized message if provided by the client - message?: { - content?: string | null; - tool_calls?: Array<{ - id: string; - type: 'function'; - function: { - name: string; - arguments: string | any; - }; - }>; - }; - // Fallbacks for OpenAI-compatible responses - choices?: Array<{ - message?: { - content?: string | null; - tool_calls?: Array<{ - id: string; - type: 'function'; - function: { - name: string; - arguments: string | any; - }; - }>; - }; - }>; - // Fallback for plain-text responses (e.g., ai SDK generateText) - text?: string | null; - - // Optional metadata if available - id?: string; - model?: string; - created?: number; - usage?: { - prompt_tokens?: number; - completion_tokens?: number; - total_tokens?: number; - }; - - [key: string]: unknown; -}; - -export interface AiSdkClient { - chat: (request: AiSdkChatRequest) => Promise; -} - export const createAiSdkProvider = ( - model: unknown, + model: LanguageModel, ): ModelProvider => { - const lm = model as LanguageModel; + const lm = model; return { async getCompletion(state, agent) { const system = agent.instructions(state); @@ -170,7 +102,7 @@ export const createAiSdkProvider = ( !hasCompletedTools && agent.tools && agent.tools.length > 0 ? agent.tools.reduce( (acc, jafTool) => { - const toSchema = zodSchema as unknown as (s: unknown) => Schema; + const toSchema = zodSchema as (s: unknown) => Schema; acc[jafTool.schema.name] = tool({ description: jafTool.schema.description, inputSchema: toSchema(jafTool.schema.parameters), @@ -184,19 +116,17 @@ export const createAiSdkProvider = ( const shouldGenerateObject = Boolean(agent.outputCodec) && !toolsForAiSDK; if (shouldGenerateObject) { - const toSchema = zodSchema as unknown as (s: unknown) => Schema; - const go = generateObject as unknown as (opts: unknown) => Promise; - const resultUnknown = await go({ + const toSchema = zodSchema as (s: unknown) => Schema; + const result = await generateObject({ model: lm, - schema: toSchema((agent.outputCodec as unknown) as import('zod').ZodType), + schema: toSchema(agent.outputCodec as import('zod').ZodType), system, messages, temperature: agent.modelConfig?.temperature, maxOutputTokens: agent.modelConfig?.maxTokens, - }); - const object = (resultUnknown as { object: unknown }).object; + }) as GenerateObjectResult; - return { message: { content: JSON.stringify(object) } }; + return { message: { content: JSON.stringify(result.object) } }; } console.log(`[DEBUG] Tools passed to AI SDK: ${toolsForAiSDK ? Object.keys(toolsForAiSDK).length : 0} (hasCompletedTools: ${hasCompletedTools})`); diff --git a/src/providers/constants.ts b/src/providers/constants.ts new file mode 100644 index 0000000..3849e26 --- /dev/null +++ b/src/providers/constants.ts @@ -0,0 +1,18 @@ +/** + * Constants for model providers + */ + +export const VISION_MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes +export const VISION_API_TIMEOUT = 3000; // 3 seconds + +export const KNOWN_VISION_MODELS = [ + 'gpt-4-vision-preview', + 'gpt-4o', + 'gpt-4o-mini', + 'claude-sonnet-4', + 'claude-sonnet-4-20250514', + 'gemini-2.5-flash', + 'gemini-2.5-pro' +] as const; + +export type KnownVisionModel = typeof KNOWN_VISION_MODELS[number]; \ No newline at end of file diff --git a/src/providers/index.ts b/src/providers/index.ts index 3c52c0f..340f595 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -12,3 +12,29 @@ export { type AiSdkClient, } from './ai-sdk'; +// Export type definitions for external use +export type { + ProxyConfig, + ProxyAgentResult, + ClientConfig, + JsonSchema, + VisionModelInfo, + VisionApiResponse, + VisionModelCacheEntry +} from './types'; + +export type { + MCPClient, + MCPToolDefinition, + MCPClientOptions +} from './mcp-types'; + +export type { + ToolCall, + ToolCallFunction, + Usage, + GenerateObjectResult, + GenerateObjectOptions, + SafeJsonParseResult +} from './ai-sdk-types'; + diff --git a/src/providers/mcp-types.ts b/src/providers/mcp-types.ts new file mode 100644 index 0000000..d255c42 --- /dev/null +++ b/src/providers/mcp-types.ts @@ -0,0 +1,61 @@ +/** + * Type definitions for MCP (Model Context Protocol) provider + */ + +import { z } from 'zod'; + +export interface JsonSchema { + type?: string; + properties?: Record; + required?: string[]; + description?: string; + enum?: string[]; + items?: JsonSchema; + [key: string]: unknown; +} + +export interface MCPToolDefinition { + name: string; + description?: string; + inputSchema?: JsonSchema; +} + +export interface MCPContentItem { + type: string; + text?: string; + [key: string]: unknown; +} + +export interface MCPToolResponse { + content?: MCPContentItem[]; + [key: string]: unknown; +} + +export interface MCPClient { + listTools(): Promise; + callTool(name: string, args: unknown): Promise; + close(): Promise; +} + +export interface MCPClientOptions { + headers?: Record; + sessionId?: string; + fetch?: typeof fetch; + requestInit?: RequestInit; +} + +export interface EventSourceModule { + default?: unknown; + [key: string]: unknown; +} + +export type ZodSchemaType = z.ZodType; + +export interface MCPToolConversionResult { + schema: { + name: string; + description: string; + parameters: z.ZodObject>; + }; + execute: (args: unknown, ctx: Ctx) => Promise; +} \ No newline at end of file diff --git a/src/providers/mcp.ts b/src/providers/mcp.ts index 7e643ef..e62e3e3 100644 --- a/src/providers/mcp.ts +++ b/src/providers/mcp.ts @@ -4,16 +4,23 @@ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { z } from 'zod'; import { Tool } from '../core/types.js'; - -export interface MCPClient { - listTools(): Promise>; - callTool(name: string, args: unknown): Promise; - close(): Promise; -} +import { + MCPClient, + MCPToolDefinition, + MCPContentItem, + MCPToolResponse, + MCPClientOptions, + EventSourceModule, + ZodSchemaType, + JsonSchema, + MCPToolConversionResult +} from './mcp-types.js'; + +export { + MCPClient, + MCPToolDefinition, + MCPClientOptions +}; /** * Create an MCP client using the STDIO transport. @@ -39,7 +46,7 @@ export async function makeMCPClient(command: string, args: string[] = []): Promi return response.tools.map(tool => ({ name: tool.name, description: tool.description, - inputSchema: tool.inputSchema + inputSchema: tool.inputSchema as JsonSchema })); } catch (error) { console.error('Failed to list MCP tools:', error); @@ -55,9 +62,9 @@ export async function makeMCPClient(command: string, args: string[] = []): Promi }); if (response.content && Array.isArray(response.content) && response.content.length > 0) { - return response.content.map((c: any) => { + return response.content.map((c: MCPContentItem) => { if (c.type === 'text') { - return c.text; + return c.text || ''; } return JSON.stringify(c); }).join('\n'); @@ -96,11 +103,11 @@ export async function makeMCPClientSSE(url: string, opts?: { headers?: Record).EventSource === 'undefined') { try { - const mod = await import('eventsource'); - const ES = (mod as any).default ?? (mod as any); - (globalThis as any).EventSource = ES; + const mod = await import('eventsource') as EventSourceModule; + const ES = mod.default ?? mod; + (globalThis as Record).EventSource = ES; } catch (e) { const msg = e instanceof Error ? e.message : String(e); throw new Error(`EventSource is not defined. Install the 'eventsource' package or run in a browser. Cause: ${msg}`); @@ -127,7 +134,7 @@ export async function makeMCPClientSSE(url: string, opts?: { headers?: Record ({ name: tool.name, description: tool.description, - inputSchema: tool.inputSchema + inputSchema: tool.inputSchema as JsonSchema })); } catch (error) { console.error('Failed to list MCP tools (SSE):', error); @@ -143,9 +150,9 @@ export async function makeMCPClientSSE(url: string, opts?: { headers?: Record 0) { - return response.content.map((c: any) => { + return response.content.map((c: MCPContentItem) => { if (c.type === 'text') { - return c.text; + return c.text || ''; } return JSON.stringify(c); }).join('\n'); @@ -230,7 +237,7 @@ export async function makeMCPClientHTTP(url: string, opts?: { return response.tools.map(tool => ({ name: tool.name, description: tool.description, - inputSchema: tool.inputSchema + inputSchema: tool.inputSchema as JsonSchema })); } catch (error) { console.error('Failed to list MCP tools (HTTP):', error); @@ -246,9 +253,9 @@ export async function makeMCPClientHTTP(url: string, opts?: { }); if (response.content && Array.isArray(response.content) && response.content.length > 0) { - return response.content.map((c: any) => { + return response.content.map((c: MCPContentItem) => { if (c.type === 'text') { - return c.text; + return c.text || ''; } return JSON.stringify(c); }).join('\n'); @@ -272,19 +279,19 @@ export async function makeMCPClientHTTP(url: string, opts?: { export function mcpToolToJAFTool( mcpClient: MCPClient, - mcpToolDef: { name: string; description?: string; inputSchema?: any } -): Tool { + mcpToolDef: MCPToolDefinition +): Tool, Ctx> { let zodSchema = jsonSchemaToZod(mcpToolDef.inputSchema || {}); // Ensure top-level OBJECT parameters for function-calling providers if (!(zodSchema instanceof z.ZodObject)) { zodSchema = z.object({ value: zodSchema }).describe('Wrapped non-object parameters'); } - const baseTool: Tool = { + const baseTool: Tool, Ctx> = { schema: { name: mcpToolDef.name, description: mcpToolDef.description ?? mcpToolDef.name, - parameters: zodSchema, + parameters: zodSchema as z.ZodObject>, }, execute: (args, _) => mcpClient.callTool(mcpToolDef.name, args), }; @@ -292,30 +299,30 @@ export function mcpToolToJAFTool( return baseTool; } -function jsonSchemaToZod(schema: any): z.ZodType { +function jsonSchemaToZod(schema: JsonSchema): ZodSchemaType { if (!schema || typeof schema !== 'object') { - return z.any(); + return z.unknown(); } if (schema.type === 'object') { - const shape: Record> = {}; - + const shape: Record = {}; + if (schema.properties) { for (const [key, prop] of Object.entries(schema.properties)) { let fieldSchema = jsonSchemaToZod(prop); - + if (!schema.required || !schema.required.includes(key)) { fieldSchema = fieldSchema.optional(); } - - if ((prop as any).description) { - fieldSchema = fieldSchema.describe((prop as any).description); + + if (prop.description) { + fieldSchema = fieldSchema.describe(prop.description); } - + shape[key] = fieldSchema; } } - + return z.object(shape); } @@ -325,7 +332,7 @@ function jsonSchemaToZod(schema: any): z.ZodType { stringSchema = stringSchema.describe(schema.description); } if (schema.enum) { - return z.enum(schema.enum); + return z.enum(schema.enum as [string, ...string[]]); } return stringSchema; } @@ -339,8 +346,8 @@ function jsonSchemaToZod(schema: any): z.ZodType { } if (schema.type === 'array') { - return z.array(jsonSchemaToZod(schema.items)); + return z.array(jsonSchemaToZod(schema.items || {})); } - return z.any(); + return z.unknown(); } diff --git a/src/providers/model.ts b/src/providers/model.ts index c7d6355..34a2acb 100644 --- a/src/providers/model.ts +++ b/src/providers/model.ts @@ -1,63 +1,22 @@ import OpenAI from "openai"; -import tunnel from 'tunnel'; import { ModelProvider, Message, MessageContentPart, getTextContent, type RunState, type Agent, type RunConfig } from '../core/types.js'; import { extractDocumentContent, isDocumentSupported, getDocumentDescription } from '../utils/document-processor.js'; - -interface ProxyConfig { - httpProxy?: string; - httpsProxy?: string; - noProxy?: string; -} - -function createProxyAgent(url?: any,proxyConfig?: ProxyConfig) { - const httpProxy = proxyConfig?.httpProxy || process.env.HTTP_PROXY; - const noProxy = proxyConfig?.noProxy || process.env.NO_PROXY; - - if (noProxy?.includes(url) || !httpProxy ) { - return undefined; - } - - try { - console.log(`[JAF:PROXY] Configuring proxy agents:`); - if (httpProxy) console.log(`HTTP_PROXY: ${httpProxy}`); - if (noProxy) console.log(`NO_PROXY: ${noProxy}`); - - return { - httpAgent: httpProxy ? createTunnelAgent(httpProxy) : undefined, - }; - } catch (error) { - console.warn(`[JAF:PROXY] Failed to create proxy agents. Install 'https-proxy-agent' and 'http-proxy-agent' packages for proxy support:`, error instanceof Error ? error.message : String(error)); - return undefined; - } -} - - -const createTunnelAgent = (proxyUrl: string) => { - const url = new URL(proxyUrl); - - // Create tunnel agent for HTTPS through HTTP proxy - return tunnel.httpsOverHttp({ - proxy: { - host: url.hostname, - port: parseInt(url.port) - }, - rejectUnauthorized: false - }); -}; +import { ProxyConfig, ClientConfig } from './types.js'; +import { createProxyAgent, isVisionModel, zodSchemaToJsonSchema } from './utils.js'; export const makeLiteLLMProvider = ( baseURL: string, apiKey = "anything", proxyConfig?: ProxyConfig ): ModelProvider => { - const clientConfig: any = { - baseURL, - apiKey, + const clientConfig: ClientConfig = { + baseURL, + apiKey, dangerouslyAllowBrowser: true }; const hostname = new URL(baseURL).hostname; - const proxyAgents = createProxyAgent(hostname,proxyConfig); + const proxyAgents = createProxyAgent(hostname, proxyConfig); if (proxyAgents) { if (proxyAgents.httpAgent) { clientConfig.httpAgent = proxyAgents.httpAgent; @@ -74,9 +33,11 @@ export const makeLiteLLMProvider = ( const { model, params } = await buildChatCompletionParams(state, agent, config, baseURL); console.log(`📞 Calling model: ${model} with params: ${JSON.stringify(params, null, 2)}`); - const resp = await client.chat.completions.create( - params as OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming - ); + const nonStreamingParams: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { + ...params, + stream: false + }; + const resp = await client.chat.completions.create(nonStreamingParams); // Return the choice with usage data attached for tracing return { @@ -148,74 +109,6 @@ export const makeLiteLLMProvider = ( }; }; -const VISION_MODEL_CACHE_TTL = 5 * 60 * 1000; -const VISION_API_TIMEOUT = 3000; -const visionModelCache = new Map(); - -async function isVisionModel(model: string, baseURL: string): Promise { - const cacheKey = `${baseURL}:${model}`; - const cached = visionModelCache.get(cacheKey); - - if (cached && Date.now() - cached.timestamp < VISION_MODEL_CACHE_TTL) { - return cached.supports; - } - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), VISION_API_TIMEOUT); - - const response = await fetch(`${baseURL}/model_group/info`, { - headers: { - 'accept': 'application/json' - }, - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (response.ok) { - const data: any = await response.json(); - const modelInfo = data.data?.find((m: any) => - m.model_group === model || model.includes(m.model_group) - ); - - if (modelInfo?.supports_vision !== undefined) { - const result = modelInfo.supports_vision; - visionModelCache.set(cacheKey, { supports: result, timestamp: Date.now() }); - return result; - } - } else { - console.warn(`Vision API returned status ${response.status} for model ${model}`); - } - } catch (error) { - if (error instanceof Error) { - if (error.name === 'AbortError') { - console.warn(`Vision API timeout for model ${model}`); - } else { - console.warn(`Vision API error for model ${model}: ${error.message}`); - } - } else { - console.warn(`Unknown error checking vision support for model ${model}`); - } - } - - const knownVisionModels = [ - 'gpt-4-vision-preview', - 'gpt-4o', - 'gpt-4o-mini', - 'claude-sonnet-4', - 'claude-sonnet-4-20250514', - 'gemini-2.5-flash', - 'gemini-2.5-pro' - ]; - - const isKnownVisionModel = knownVisionModels.some(visionModel => - model.toLowerCase().includes(visionModel.toLowerCase()) - ); - - visionModelCache.set(cacheKey, { supports: isKnownVisionModel, timestamp: Date.now() }); - - return isKnownVisionModel; -} /** * Build common Chat Completions request parameters shared by both @@ -234,10 +127,14 @@ async function buildChatCompletionParams( } // Vision capability check if any image payload present - const hasImageContent = state.messages.some(msg => - (Array.isArray(msg.content) && msg.content.some(part => (part as any).type === 'image_url')) || - (!!msg.attachments && msg.attachments.some(att => att.kind === 'image')) - ); + const hasImageContent = state.messages.some(msg => { + if (Array.isArray(msg.content)) { + return msg.content.some(part => + part && typeof part === 'object' && 'type' in part && part.type === 'image_url' + ); + } + return !!msg.attachments && msg.attachments.some(att => att.kind === 'image'); + }); if (hasImageContent) { const supportsVision = await isVisionModel(model, baseURL); if (!supportsVision) { @@ -298,7 +195,7 @@ async function convertMessage(msg: Message): Promise 0; if (!hasAttachments) { if (role === 'assistant') { - return { role: 'assistant', content: getTextContent(msg.content), tool_calls: msg.tool_calls as any }; + return { role: 'assistant', content: getTextContent(msg.content), tool_calls: msg.tool_calls ? [...msg.tool_calls] : undefined }; } return { role: 'user', content: getTextContent(msg.content) }; } - const parts: any[] = []; + const parts: OpenAI.Chat.Completions.ChatCompletionContentPart[] = []; const textContent = getTextContent(msg.content); if (textContent && textContent.trim().length > 0) { parts.push({ type: 'text', text: textContent }); @@ -383,8 +279,7 @@ async function buildChatMessageWithAttachments( parts.push({ type: 'file', file: { - file_id, - format: att.mimeType || att.format + file_id } }); } @@ -420,66 +315,17 @@ async function buildChatMessageWithAttachments( } } - const base: any = { role, content: parts }; if (role === 'assistant' && msg.tool_calls) { - base.tool_calls = msg.tool_calls as any; - } - return base as OpenAI.Chat.Completions.ChatCompletionMessageParam; -} - -function zodSchemaToJsonSchema(zodSchema: any): any { - if (zodSchema._def?.typeName === 'ZodObject') { - const properties: Record = {}; - const required: string[] = []; - - for (const [key, value] of Object.entries(zodSchema._def.shape())) { - properties[key] = zodSchemaToJsonSchema(value); - if (!(value as any).isOptional()) { - required.push(key); - } - } - - return { - type: 'object', - properties, - required: required.length > 0 ? required : undefined, - additionalProperties: false - }; - } - - if (zodSchema._def?.typeName === 'ZodString') { - const schema: any = { type: 'string' }; - if (zodSchema._def.description) { - schema.description = zodSchema._def.description; - } - return schema; - } - - if (zodSchema._def?.typeName === 'ZodNumber') { - return { type: 'number' }; - } - - if (zodSchema._def?.typeName === 'ZodBoolean') { - return { type: 'boolean' }; - } - - if (zodSchema._def?.typeName === 'ZodArray') { return { - type: 'array', - items: zodSchemaToJsonSchema(zodSchema._def.type) + role: 'assistant', + content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text : null, + tool_calls: msg.tool_calls ? [...msg.tool_calls] : undefined }; } - - if (zodSchema._def?.typeName === 'ZodOptional') { - return zodSchemaToJsonSchema(zodSchema._def.innerType); - } - - if (zodSchema._def?.typeName === 'ZodEnum') { - return { - type: 'string', - enum: zodSchema._def.values - }; - } - - return { type: 'string', description: 'Unsupported schema type' }; + + return { + role: 'user', + content: parts + }; } + diff --git a/src/providers/types.ts b/src/providers/types.ts new file mode 100644 index 0000000..3f6f7e9 --- /dev/null +++ b/src/providers/types.ts @@ -0,0 +1,45 @@ +/** + * Type definitions for model providers + */ + +export interface ProxyConfig { + httpProxy?: string; + httpsProxy?: string; + noProxy?: string; +} + +export interface ProxyAgentResult { + httpAgent?: any; // TunnelAgent type is not properly exported +} + +export interface ClientConfig { + baseURL: string; + apiKey: string; + dangerouslyAllowBrowser: boolean; + httpAgent?: any; // TunnelAgent type is not properly exported +} + +export interface JsonSchema { + type: string; + properties?: Record; + required?: string[]; + additionalProperties?: boolean; + description?: string; + items?: JsonSchema; + enum?: string[]; + [key: string]: unknown; +} + +export interface VisionModelInfo { + model_group: string; + supports_vision?: boolean; +} + +export interface VisionApiResponse { + data?: VisionModelInfo[]; +} + +export interface VisionModelCacheEntry { + supports: boolean; + timestamp: number; +} \ No newline at end of file diff --git a/src/providers/utils.ts b/src/providers/utils.ts new file mode 100644 index 0000000..d267955 --- /dev/null +++ b/src/providers/utils.ts @@ -0,0 +1,157 @@ +/** + * Utility functions for model providers + */ + +import tunnel from 'tunnel'; +import { ProxyAgentResult, ProxyConfig, VisionModelCacheEntry, JsonSchema } from './types.js'; +import { VISION_MODEL_CACHE_TTL, VISION_API_TIMEOUT, KNOWN_VISION_MODELS } from './constants.js'; + +export function createProxyAgent(hostname?: string, proxyConfig?: ProxyConfig): ProxyAgentResult | undefined { + const httpProxy = proxyConfig?.httpProxy || process.env.HTTP_PROXY; + const noProxy = proxyConfig?.noProxy || process.env.NO_PROXY; + + if ((hostname && noProxy?.includes(hostname)) || !httpProxy) { + return undefined; + } + + try { + console.log(`[JAF:PROXY] Configuring proxy agents:`); + if (httpProxy) console.log(`HTTP_PROXY: ${httpProxy}`); + if (noProxy) console.log(`NO_PROXY: ${noProxy}`); + + return { + httpAgent: httpProxy ? createTunnelAgent(httpProxy) : undefined, + }; + } catch (error) { + console.warn(`[JAF:PROXY] Failed to create proxy agents. Install 'https-proxy-agent' and 'http-proxy-agent' packages for proxy support:`, error instanceof Error ? error.message : String(error)); + return undefined; + } +} + +export const createTunnelAgent = (proxyUrl: string) => { + const url = new URL(proxyUrl); + + // Create tunnel agent for HTTPS through HTTP proxy + return tunnel.httpsOverHttp({ + proxy: { + host: url.hostname, + port: parseInt(url.port) + }, + rejectUnauthorized: false + }); +}; + +const visionModelCache = new Map(); + +export async function isVisionModel(model: string, baseURL: string): Promise { + const cacheKey = `${baseURL}:${model}`; + const cached = visionModelCache.get(cacheKey); + + if (cached && Date.now() - cached.timestamp < VISION_MODEL_CACHE_TTL) { + return cached.supports; + } + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), VISION_API_TIMEOUT); + + const response = await fetch(`${baseURL}/model_group/info`, { + headers: { + 'accept': 'application/json' + }, + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (response.ok) { + const data = await response.json() as { data?: Array<{ model_group: string; supports_vision?: boolean }> }; + const modelInfo = data.data?.find((m) => + m.model_group === model || model.includes(m.model_group) + ); + + if (modelInfo?.supports_vision !== undefined) { + const result = modelInfo.supports_vision; + visionModelCache.set(cacheKey, { supports: result, timestamp: Date.now() }); + return result; + } + } else { + console.warn(`Vision API returned status ${response.status} for model ${model}`); + } + } catch (error) { + if (error instanceof Error) { + if (error.name === 'AbortError') { + console.warn(`Vision API timeout for model ${model}`); + } else { + console.warn(`Vision API error for model ${model}: ${error.message}`); + } + } else { + console.warn(`Unknown error checking vision support for model ${model}`); + } + } + + const isKnownVisionModel = KNOWN_VISION_MODELS.some(visionModel => + model.toLowerCase().includes(visionModel.toLowerCase()) + ); + + visionModelCache.set(cacheKey, { supports: isKnownVisionModel, timestamp: Date.now() }); + + return isKnownVisionModel; +} + +export function zodSchemaToJsonSchema(zodSchema: any): JsonSchema { + if (zodSchema._def?.typeName === 'ZodObject') { + const properties: Record = {}; + const required: string[] = []; + + for (const [key, value] of Object.entries(zodSchema._def.shape())) { + properties[key] = zodSchemaToJsonSchema(value); + if (typeof value === 'object' && value && 'isOptional' in value && typeof (value as any).isOptional === 'function' && !(value as any).isOptional()) { + required.push(key); + } + } + + return { + type: 'object', + properties, + required: required.length > 0 ? required : undefined, + additionalProperties: false + }; + } + + if (zodSchema._def?.typeName === 'ZodString') { + const schema: JsonSchema = { type: 'string' }; + if (zodSchema._def.description) { + schema.description = zodSchema._def.description; + } + return schema; + } + + if (zodSchema._def?.typeName === 'ZodNumber') { + return { type: 'number' }; + } + + if (zodSchema._def?.typeName === 'ZodBoolean') { + return { type: 'boolean' }; + } + + if (zodSchema._def?.typeName === 'ZodArray') { + return { + type: 'array', + items: zodSchemaToJsonSchema(zodSchema._def.type as any) + }; + } + + if (zodSchema._def?.typeName === 'ZodOptional') { + return zodSchemaToJsonSchema(zodSchema._def.innerType as any); + } + + if (zodSchema._def?.typeName === 'ZodEnum') { + return { + type: 'string', + enum: zodSchema._def.values + }; + } + + return { type: 'string', description: 'Unsupported schema type' }; +} \ No newline at end of file