From b58d45463599dfe515e6dac8fed60d5bf60d7727 Mon Sep 17 00:00:00 2001 From: Shailendra Pratap Singh Date: Mon, 4 May 2026 23:08:36 +0530 Subject: [PATCH] feat/cdac --- .../__tests__/generic-openai.test.ts | 555 ++++++++++++++++++ src/providers/generic-openai.ts | 458 +++++++++++++++ src/providers/index.ts | 1 + src/providers/model.ts | 2 +- 4 files changed, 1015 insertions(+), 1 deletion(-) create mode 100644 src/providers/__tests__/generic-openai.test.ts create mode 100644 src/providers/generic-openai.ts diff --git a/src/providers/__tests__/generic-openai.test.ts b/src/providers/__tests__/generic-openai.test.ts new file mode 100644 index 0000000..3e8841b --- /dev/null +++ b/src/providers/__tests__/generic-openai.test.ts @@ -0,0 +1,555 @@ +import { + parseThinkingContent, + parseXmlToolCalls, + stripXmlToolCalls, + makeGenericOpenAIProvider, +} from '../generic-openai'; +import type { RunState, Agent, RunConfig, ToolCall } from '../../core/types'; +import { z } from 'zod'; + +// ========== Unit Tests: Parsers ========== + +describe('parseThinkingContent', () => { + it('should separate thinking from content when is present', () => { + const raw = 'I need to search for this.\n\n\nHere is the answer.'; + const result = parseThinkingContent(raw); + expect(result.thinking).toBe('I need to search for this.'); + expect(result.content).toBe('Here is the answer.'); + }); + + it('should return null thinking when no tag', () => { + const raw = 'Just a normal response with no thinking.'; + const result = parseThinkingContent(raw); + expect(result.thinking).toBeNull(); + expect(result.content).toBe('Just a normal response with no thinking.'); + }); + + it('should handle at the very beginning', () => { + const raw = '\nActual content here.'; + const result = parseThinkingContent(raw); + expect(result.thinking).toBeNull(); // empty string becomes null + expect(result.content).toBe('Actual content here.'); + }); + + it('should handle at the very end', () => { + const raw = 'All thinking, no content.'; + const result = parseThinkingContent(raw); + expect(result.thinking).toBe('All thinking, no content.'); + expect(result.content).toBe(''); + }); + + it('should handle multi-line thinking content', () => { + const raw = 'Step 1: analyze\nStep 2: plan\nStep 3: execute\n\n\nFinal answer.'; + const result = parseThinkingContent(raw); + expect(result.thinking).toBe('Step 1: analyze\nStep 2: plan\nStep 3: execute'); + expect(result.content).toBe('Final answer.'); + }); + + it('should handle empty string', () => { + const result = parseThinkingContent(''); + expect(result.thinking).toBeNull(); + expect(result.content).toBe(''); + }); +}); + +describe('parseXmlToolCalls', () => { + it('should parse a single tool call', () => { + const text = ` + + +Platform Credential Generation + +`; + const result = parseXmlToolCalls(text); + expect(result).toHaveLength(1); + expect(result[0].type).toBe('function'); + expect(result[0].function.name).toBe('searchGlobal'); + const args = JSON.parse(result[0].function.arguments); + expect(args.query).toBe('Platform Credential Generation'); + }); + + it('should parse multiple tool calls', () => { + const text = ` + + +Find info + + + + +test query +10 + +`; + const result = parseXmlToolCalls(text); + expect(result).toHaveLength(2); + expect(result[0].function.name).toBe('toDoWrite'); + expect(result[1].function.name).toBe('searchGlobal'); + const args1 = JSON.parse(result[1].function.arguments); + expect(args1.limit).toBe(10); + }); + + it('should parse JSON array parameters', () => { + const text = ` + + +Determine requirement +[{"id": "search_info", "description": "Search for info", "status": "pending", "toolsRequired": ["searchGlobal"]}] + +`; + const result = parseXmlToolCalls(text); + expect(result).toHaveLength(1); + const args = JSON.parse(result[0].function.arguments); + expect(args.goal).toBe('Determine requirement'); + expect(Array.isArray(args.subTasks)).toBe(true); + expect(args.subTasks[0].id).toBe('search_info'); + }); + + it('should parse boolean parameters', () => { + const text = ` + + +true +false + +`; + const result = parseXmlToolCalls(text); + const args = JSON.parse(result[0].function.arguments); + expect(args.enabled).toBe(true); + expect(args.verbose).toBe(false); + }); + + it('should return empty array for text with no tool calls', () => { + const result = parseXmlToolCalls('Just some normal text with no tools.'); + expect(result).toEqual([]); + }); + + it('should generate deterministic IDs', () => { + const text = `test`; + const result1 = parseXmlToolCalls(text); + const result2 = parseXmlToolCalls(text); + expect(result1[0].id).toBe(result2[0].id); + }); +}); + +describe('stripXmlToolCalls', () => { + it('should remove tool call XML and leave surrounding text', () => { + const text = `Here is my plan.\n\n\ntest\n\n\n`; + const result = stripXmlToolCalls(text); + expect(result).toBe('Here is my plan.'); + }); + + it('should handle multiple tool call blocks', () => { + const text = `Intro text.1 Middle. 2 End.`; + const result = stripXmlToolCalls(text); + expect(result).toBe('Intro text. Middle. End.'); + }); + + it('should return text unchanged if no tool calls', () => { + const text = 'No tool calls here.'; + expect(stripXmlToolCalls(text)).toBe('No tool calls here.'); + }); +}); + +// ========== Integration: Full Response Parsing (via getCompletion) ========== + +describe('makeGenericOpenAIProvider', () => { + // Mock the docs.txt response format + const MOCK_RESPONSE_WITH_THINKING_AND_TOOLS = { + id: 'chatcmpl-test123', + object: 'chat.completion', + created: 1777533657, + model: 'nemotron-3-120b-a12b-bf16', + choices: [{ + index: 0, + message: { + role: 'assistant', + content: 'We need to search for this info. Let me plan.\n\n\n\n\nFind the answer\n[{"id":"s1","description":"Search","status":"pending","toolsRequired":["searchGlobal"]}]\n\n\n', + tool_calls: [], + reasoning: null, + }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 }, + }; + + const MOCK_RESPONSE_PLAIN_TEXT = { + id: 'chatcmpl-plain', + object: 'chat.completion', + created: 1777533657, + model: 'nemotron-3-120b-a12b-bf16', + choices: [{ + index: 0, + message: { + role: 'assistant', + content: 'The answer is 42.', + tool_calls: [], + }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 50, completion_tokens: 10, total_tokens: 60 }, + }; + + const MOCK_RESPONSE_NATIVE_TOOL_CALLS = { + id: 'chatcmpl-native', + object: 'chat.completion', + created: 1777533657, + model: 'gpt-4o', + choices: [{ + index: 0, + message: { + role: 'assistant', + content: '', + tool_calls: [{ + id: 'call_abc123', + type: 'function', + function: { name: 'searchGlobal', arguments: '{"query":"test"}' }, + }], + }, + finish_reason: 'tool_calls', + }], + usage: { prompt_tokens: 80, completion_tokens: 20, total_tokens: 100 }, + }; + + // Helpers + const makeState = (): RunState => ({ + runId: 'run-1' as any, + traceId: 'trace-1' as any, + messages: [{ role: 'user', content: 'Hello' }], + currentAgentName: 'TestAgent', + context: {}, + turnCount: 0, + }); + + const makeAgent = (): Agent => ({ + name: 'TestAgent', + instructions: () => 'You are a test agent.', + tools: [{ + schema: { + name: 'searchGlobal', + description: 'Search globally', + parameters: z.object({ query: z.string() }), + }, + execute: async () => 'result', + }], + modelConfig: { name: 'nemotron-3-120b-a12b-bf16' }, + }); + + const makeConfig = (provider: any): RunConfig => ({ + agentRegistry: new Map([['TestAgent', makeAgent()]]), + modelProvider: provider, + }); + + beforeEach(() => { + // Reset fetch mock before each test + (global as any).fetch = undefined; + }); + + afterEach(() => { + delete (global as any).fetch; + }); + + function mockFetch(responseBody: any, status = 200) { + (global as any).fetch = jest.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + status, + json: async () => responseBody, + text: async () => JSON.stringify(responseBody), + }); + } + + // ---------- getCompletion tests ---------- + + describe('getCompletion', () => { + it('should parse thinking + XML tool calls from content (docs.txt format)', async () => { + mockFetch(MOCK_RESPONSE_WITH_THINKING_AND_TOOLS); + + const provider = makeGenericOpenAIProvider('test-key', { baseURL: 'https://test.api/v1' }); + const result = await provider.getCompletion(makeState(), makeAgent(), makeConfig(provider)); + + // Content should be clean — no thinking, no XML + expect(result.message?.content).toBe(''); + // Tool calls should be parsed from XML + expect(result.message?.tool_calls).toHaveLength(1); + expect(result.message!.tool_calls![0].function.name).toBe('toDoWrite'); + const args = JSON.parse(result.message!.tool_calls![0].function.arguments); + expect(args.goal).toBe('Find the answer'); + expect(Array.isArray(args.subTasks)).toBe(true); + }); + + it('should handle plain text response (no thinking, no tools)', async () => { + mockFetch(MOCK_RESPONSE_PLAIN_TEXT); + + const provider = makeGenericOpenAIProvider('test-key', { baseURL: 'https://test.api/v1' }); + const result = await provider.getCompletion(makeState(), makeAgent(), makeConfig(provider)); + + expect(result.message?.content).toBe('The answer is 42.'); + expect(result.message?.tool_calls).toBeUndefined(); + }); + + it('should fall back to native tool_calls when no XML tool calls found', async () => { + mockFetch(MOCK_RESPONSE_NATIVE_TOOL_CALLS); + + const provider = makeGenericOpenAIProvider('test-key', { baseURL: 'https://test.api/v1' }); + const result = await provider.getCompletion(makeState(), makeAgent(), makeConfig(provider)); + + expect(result.message?.tool_calls).toHaveLength(1); + expect(result.message!.tool_calls![0].id).toBe('call_abc123'); + expect(result.message!.tool_calls![0].function.name).toBe('searchGlobal'); + }); + + it('should send correct request body structure', async () => { + mockFetch(MOCK_RESPONSE_PLAIN_TEXT); + + const provider = makeGenericOpenAIProvider('test-key', { + baseURL: 'https://test.api/v1', + defaultHeaders: { 'X-Custom': 'header' }, + }); + await provider.getCompletion(makeState(), makeAgent(), makeConfig(provider)); + + const fetchCall = (global as any).fetch; + expect(fetchCall).toHaveBeenCalledTimes(1); + + const [url, options] = fetchCall.mock.calls[0]; + expect(url).toBe('https://test.api/v1/chat/completions'); + expect(options.method).toBe('POST'); + expect(options.headers['Authorization']).toBe('Bearer test-key'); + expect(options.headers['X-Custom']).toBe('header'); + + const body = JSON.parse(options.body); + expect(body.model).toBe('nemotron-3-120b-a12b-bf16'); + expect(body.messages[0].role).toBe('system'); + expect(body.messages[0].content).toBe('You are a test agent.'); + expect(body.messages[1].role).toBe('user'); + expect(body.messages[1].content).toBe('Hello'); + expect(body.tools).toHaveLength(1); + expect(body.tools[0].function.name).toBe('searchGlobal'); + }); + + it('should throw on non-OK response', async () => { + mockFetch({ error: 'Unauthorized' }, 401); + + const provider = makeGenericOpenAIProvider('bad-key', { baseURL: 'https://test.api/v1' }); + + await expect( + provider.getCompletion(makeState(), makeAgent(), makeConfig(provider)) + ).rejects.toThrow('GenericOpenAI API error 401'); + }); + }); + + // ---------- getCompletionStream tests ---------- + + describe('getCompletionStream', () => { + function mockStreamFetch(sseChunks: string[]) { + const encoder = new TextEncoder(); + let chunkIndex = 0; + + const readableStream = new ReadableStream({ + pull(controller) { + if (chunkIndex < sseChunks.length) { + controller.enqueue(encoder.encode(sseChunks[chunkIndex])); + chunkIndex++; + } else { + controller.close(); + } + }, + }); + + (global as any).fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + body: readableStream, + }); + } + + it('should buffer thinking and only yield clean content', async () => { + // Simulate SSE chunks: thinking text, then , then clean content, then done + const sseChunks = [ + 'data: {"choices":[{"delta":{"content":"Let me think"},"index":0}]}\n\n', + 'data: {"choices":[{"delta":{"content":" about this."},"index":0}]}\n\n', + 'data: {"choices":[{"delta":{"content":"\\n\\n\\n"},"index":0}]}\n\n', + 'data: {"choices":[{"delta":{"content":"Here is the answer."},"index":0}]}\n\n', + 'data: {"choices":[{"index":0,"finish_reason":"stop"}]}\n\n', + 'data: [DONE]\n\n', + ]; + mockStreamFetch(sseChunks); + + const provider = makeGenericOpenAIProvider('key', { baseURL: 'https://test.api/v1' }); + const stream = provider.getCompletionStream!(makeState(), makeAgent(), makeConfig(provider)); + + const deltas: string[] = []; + const toolCallDeltas: any[] = []; + let isDone = false; + + for await (const chunk of stream) { + if (chunk.delta) deltas.push(chunk.delta); + if (chunk.toolCallDelta) toolCallDeltas.push(chunk.toolCallDelta); + if (chunk.isDone) isDone = true; + } + + // Should NOT contain thinking text + const fullContent = deltas.join(''); + expect(fullContent).not.toContain('Let me think'); + expect(fullContent).not.toContain(''); + expect(fullContent).toContain('Here is the answer.'); + expect(isDone).toBe(true); + }); + + it('should emit tool calls as toolCallDeltas at stream end', async () => { + // Content with thinking + XML tool call + const content = 'Thinking...\n\n\n\n\ntest\n\n\n'; + // Send it in one big chunk for simplicity + const sseChunks = [ + `data: {"choices":[{"delta":{"content":${JSON.stringify(content)}},"index":0}]}\n\n`, + 'data: {"choices":[{"index":0,"finish_reason":"stop"}]}\n\n', + 'data: [DONE]\n\n', + ]; + mockStreamFetch(sseChunks); + + const provider = makeGenericOpenAIProvider('key', { baseURL: 'https://test.api/v1' }); + const stream = provider.getCompletionStream!(makeState(), makeAgent(), makeConfig(provider)); + + const deltas: string[] = []; + const toolCallDeltas: any[] = []; + + for await (const chunk of stream) { + if (chunk.delta) deltas.push(chunk.delta); + if (chunk.toolCallDelta) toolCallDeltas.push(chunk.toolCallDelta); + } + + // Content should not have XML tool calls + const fullContent = deltas.join(''); + expect(fullContent).not.toContain(''); + expect(fullContent).not.toContain('Thinking'); + + // Should have one tool call delta + expect(toolCallDeltas).toHaveLength(1); + expect(toolCallDeltas[0].function.name).toBe('searchGlobal'); + const args = JSON.parse(toolCallDeltas[0].function.argumentsDelta); + expect(args.query).toBe('test'); + }); + + it('should handle response with no thinking (no tag)', async () => { + const sseChunks = [ + 'data: {"choices":[{"delta":{"content":"Direct answer."},"index":0}]}\n\n', + 'data: {"choices":[{"index":0,"finish_reason":"stop"}]}\n\n', + 'data: [DONE]\n\n', + ]; + mockStreamFetch(sseChunks); + + const provider = makeGenericOpenAIProvider('key', { baseURL: 'https://test.api/v1' }); + const stream = provider.getCompletionStream!(makeState(), makeAgent(), makeConfig(provider)); + + const deltas: string[] = []; + for await (const chunk of stream) { + if (chunk.delta) deltas.push(chunk.delta); + } + + expect(deltas.join('')).toBe('Direct answer.'); + }); + + it('should handle native tool call deltas from models that support them', async () => { + const sseChunks = [ + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"search","arguments":""}}]},"index":0}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"q\\":"}}]},"index":0}]}\n\n', + 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"test\\"}"}}]},"index":0}]}\n\n', + 'data: {"choices":[{"index":0,"finish_reason":"tool_calls"}]}\n\n', + 'data: [DONE]\n\n', + ]; + mockStreamFetch(sseChunks); + + const provider = makeGenericOpenAIProvider('key', { baseURL: 'https://test.api/v1' }); + const stream = provider.getCompletionStream!(makeState(), makeAgent(), makeConfig(provider)); + + const toolCallDeltas: any[] = []; + for await (const chunk of stream) { + if (chunk.toolCallDelta) toolCallDeltas.push(chunk.toolCallDelta); + } + + expect(toolCallDeltas.length).toBeGreaterThanOrEqual(3); + expect(toolCallDeltas[0].id).toBe('call_1'); + expect(toolCallDeltas[0].function.name).toBe('search'); + }); + + it('should capture usage from final stream chunk', async () => { + const sseChunks = [ + 'data: {"choices":[{"delta":{"content":"Hi"},"index":0}]}\n\n', + 'data: {"choices":[{"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}\n\n', + 'data: [DONE]\n\n', + ]; + mockStreamFetch(sseChunks); + + const provider = makeGenericOpenAIProvider('key', { baseURL: 'https://test.api/v1' }); + const stream = provider.getCompletionStream!(makeState(), makeAgent(), makeConfig(provider)); + + let finalChunk: any = null; + for await (const chunk of stream) { + if (chunk.isDone) finalChunk = chunk; + } + + expect(finalChunk).not.toBeNull(); + expect(finalChunk.usage?.prompt_tokens).toBe(10); + expect(finalChunk.usage?.completion_tokens).toBe(5); + }); + }); +}); + +// ========== End-to-end: Full docs.txt response format ========== + +describe('docs.txt response format (end-to-end)', () => { + it('should correctly parse the exact response from docs.txt', () => { + // This is the actual content from docs.txt + const rawContent = `We need to answer: "Find whether Platform Credential Generation is required before Service Subscription and explain why." This is likely about some system/platform, possibly related to enterprise software, maybe Salesforce? Or maybe "Platform Credential Generation" refers to something like generating platform credentials (API tokens, etc.) before subscribing to a service. Might be from some internal documentation. + +We need to use tools if needed. We don't have any prior context. We need to search for information about "Platform Credential Generation required before Service Subscription". Let's search globally. + +We'll start by creating a plan with toDoWrite. Then call searchGlobal. + +Plan: Goal: Determine whether Platform Credential Generation is required before Service Subscription and explain why. + +Subtasks: 1) Search for relevant information. 2) Analyze results. 3) Provide answer. + +We need to call toDoWrite first. Then after that, we can call searchGlobal. + +Let's do that. + + + + + +Determine whether Platform Credential Generation is required before Service Subscription and explain why. + + +[{"id": "search_info", "description": "Search for information about Platform Credential Generation and Service Subscription requirements.", "status": "pending", "toolsRequired": ["searchGlobal"], "result": ""}, {"id": "analyze", "description": "Analyze search results to determine requirement and rationale.", "status": "pending", "toolsRequired": [], "result": ""}, {"id": "answer", "description": "Prepare final answer with explanation.", "status": "pending", "toolsRequired": [], "result": ""}] + + + +`; + + // Test thinking separation + const { thinking, content } = parseThinkingContent(rawContent); + expect(thinking).not.toBeNull(); + expect(thinking).toContain('We need to answer'); + expect(thinking).toContain("Let's do that."); + expect(thinking).not.toContain(''); + + // Test tool call parsing + const toolCalls = parseXmlToolCalls(content); + expect(toolCalls).toHaveLength(1); + expect(toolCalls[0].type).toBe('function'); + expect(toolCalls[0].function.name).toBe('toDoWrite'); + + const args = JSON.parse(toolCalls[0].function.arguments); + expect(args.goal).toContain('Platform Credential Generation'); + expect(Array.isArray(args.subTasks)).toBe(true); + expect(args.subTasks).toHaveLength(3); + expect(args.subTasks[0].id).toBe('search_info'); + expect(args.subTasks[0].toolsRequired).toContain('searchGlobal'); + + // Test content stripping + const cleanContent = stripXmlToolCalls(content); + expect(cleanContent).not.toContain(''); + expect(cleanContent).not.toContain('; + timeoutMs?: number; +} + +interface ParsedResponse { + thinking: string | null; + content: string; + toolCalls: readonly ToolCall[]; +} + +// ========== Thinking Parser ========== + +export function parseThinkingContent(raw: string): { thinking: string | null; content: string } { + const idx = raw.indexOf(''); + if (idx === -1) { + return { thinking: null, content: raw }; + } + const thinking = raw.slice(0, idx).trim(); + const content = raw.slice(idx + ''.length).trim(); + return { thinking: thinking || null, content }; +} + +// ========== XML Tool Call Parser ========== + +function generateToolCallId(name: string, params: Record): string { + const payload = JSON.stringify({ name, params }); + const hash = createHash('md5').update(payload).digest('hex').slice(0, 8); + return `call_${name}_${hash}`; +} + +export function parseXmlToolCalls(text: string): ToolCall[] { + const toolCalls: ToolCall[] = []; + const toolCallRegex = /\s*([\s\S]*?)<\/function>\s*<\/tool_call>/g; + let match; + + while ((match = toolCallRegex.exec(text)) !== null) { + const funcName = match[1]; + const body = match[2]; + const params: Record = {}; + + const paramRegex = /([\s\S]*?)<\/parameter>/g; + let paramMatch; + while ((paramMatch = paramRegex.exec(body)) !== null) { + const key = paramMatch[1]; + const rawValue = paramMatch[2].trim(); + + // Attempt JSON parse for structured values (arrays, objects, numbers, booleans) + let value: unknown = rawValue; + if ( + (rawValue.startsWith('[') && rawValue.endsWith(']')) || + (rawValue.startsWith('{') && rawValue.endsWith('}')) + ) { + try { + value = JSON.parse(rawValue); + } catch { + // keep as string + } + } else if (rawValue === 'true' || rawValue === 'false') { + value = rawValue === 'true'; + } else if (rawValue !== '' && !isNaN(Number(rawValue)) && isFinite(Number(rawValue))) { + value = Number(rawValue); + } + + params[key] = value; + } + + toolCalls.push({ + id: generateToolCallId(funcName, params), + type: 'function', + function: { + name: funcName, + arguments: JSON.stringify(params), + }, + }); + } + + return toolCalls; +} + +export function stripXmlToolCalls(text: string): string { + return text + .replace(/[\s\S]*?<\/tool_call>/g, '') + .trim(); +} + +// ========== Full Response Parser ========== + +function parseFullResponse(json: any): ParsedResponse & { usage?: any; model?: string; id?: string } { + const choice = json?.choices?.[0]; + const rawContent: string = choice?.message?.content ?? ''; + + const { thinking, content: afterThink } = parseThinkingContent(rawContent); + + // Try XML tool calls first + let toolCalls = parseXmlToolCalls(afterThink); + let cleanContent = stripXmlToolCalls(afterThink); + + // Fall back to native tool_calls array if no XML tool calls found + if (toolCalls.length === 0 && Array.isArray(choice?.message?.tool_calls) && choice.message.tool_calls.length > 0) { + toolCalls = choice.message.tool_calls.map((tc: any) => ({ + id: tc.id, + type: 'function' as const, + function: { + name: tc.function.name, + arguments: typeof tc.function.arguments === 'string' + ? tc.function.arguments + : JSON.stringify(tc.function.arguments), + }, + })); + // When using native tool calls, content stays as-is + cleanContent = afterThink; + } + + return { + thinking, + content: cleanContent, + toolCalls, + usage: json?.usage, + model: json?.model, + id: json?.id, + }; +} + +// ========== Request Building ========== + +function convertMessage(msg: Message): Record { + const textContent = getTextContent(msg.content); + switch (msg.role) { + case 'user': + return { role: 'user', content: textContent }; + case 'assistant': { + const m: Record = { role: 'assistant', content: textContent }; + if (msg.tool_calls && msg.tool_calls.length > 0) { + m.tool_calls = msg.tool_calls; + } + return m; + } + case 'tool': + return { role: 'tool', content: textContent, tool_call_id: msg.tool_call_id }; + default: + return { role: 'user', content: textContent }; + } +} + +function buildRequestBody( + state: Readonly>, + agent: Readonly>, + config: Readonly>, +): { model: string; body: Record } { + const model = agent.modelConfig?.name ?? config.modelOverride; + if (!model) { + throw new Error(`Model not specified for agent ${agent.name}`); + } + + const systemMessage = { role: 'system', content: agent.instructions(state) }; + const messages = [systemMessage, ...state.messages.map(convertMessage)]; + + const tools = agent.tools?.map(t => ({ + type: 'function', + function: { + name: t.schema.name, + description: t.schema.description, + parameters: zodSchemaToJsonSchema(t.schema.parameters), + }, + })); + + const body: Record = { + model, + messages, + temperature: agent.modelConfig?.temperature, + max_tokens: agent.modelConfig?.maxTokens, + tools: tools && tools.length > 0 ? tools : undefined, + }; + + if (agent.modelConfig?.reasoning?.effort) { + body.reasoning_effort = agent.modelConfig.reasoning.effort; + } + + return { model, body }; +} + +// ========== SSE Stream Parser ========== + +async function* parseSseStream(response: Response): AsyncGenerator { + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop()!; // keep the incomplete last line + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === 'data: [DONE]') return; + if (trimmed.startsWith('data: ')) { + try { + yield JSON.parse(trimmed.slice(6)); + } catch { + // skip malformed JSON lines + } + } + } + } + } finally { + reader.releaseLock(); + } +} + +// ========== Provider Factory ========== + +export const makeGenericOpenAIProvider = ( + apiKey: string, + options?: GenericOpenAIProviderOptions, +): ModelProvider => { + const baseURL = (options?.baseURL ?? '').replace(/\/+$/, ''); + const timeoutMs = options?.timeoutMs ?? 120_000; + const defaultHeaders = options?.defaultHeaders ?? {}; + + const headers: Record = { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + ...defaultHeaders, + }; + + return { + async getCompletion(state, agent, config) { + const { model, body } = buildRequestBody(state, agent, config); + + if (isVerboseLogging()) { + safeConsole.log(`[JAF:GENERIC] Calling model: ${model} with body: ${JSON.stringify(body, null, 2)}`); + } else { + const lastMsg = state.messages[state.messages.length - 1]; + safeConsole.log( + `[JAF:GENERIC] Calling model: ${model} | messages: ${state.messages.length + 1} | last: "${String(getTextContent(lastMsg?.content) ?? '').slice(0, 120)}"`, + ); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(`${baseURL}/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: controller.signal, + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => 'unknown'); + safeConsole.error(`[JAF:GENERIC] getCompletion failed for model=${model}`, { + status: response.status, + responseBody: errorBody, + }); + throw new Error(`GenericOpenAI API error ${response.status}: ${errorBody}`); + } + + const json = await response.json(); + const parsed = parseFullResponse(json); + + return { + message: { + content: parsed.content || null, + tool_calls: parsed.toolCalls.length > 0 ? parsed.toolCalls : undefined, + }, + usage: parsed.usage, + model: parsed.model, + id: parsed.id, + thinking: parsed.thinking, + } as any; + } finally { + clearTimeout(timeout); + } + }, + + async *getCompletionStream(state, agent, config) { + const { model, body } = buildRequestBody(state, agent, config); + body.stream = true; + body.stream_options = { include_usage: true }; + + if (isVerboseLogging()) { + safeConsole.log(`[JAF:GENERIC] Streaming model: ${model} with body: ${JSON.stringify(body, null, 2)}`); + } else { + const lastMsg = state.messages[state.messages.length - 1]; + safeConsole.log( + `[JAF:GENERIC] Streaming model: ${model} | messages: ${state.messages.length + 1} | last: "${String(getTextContent(lastMsg?.content) ?? '').slice(0, 120)}"`, + ); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + let response: Response; + try { + response = await fetch(`${baseURL}/chat/completions`, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: controller.signal, + }); + } catch (error) { + clearTimeout(timeout); + safeConsole.error(`[JAF:GENERIC] getCompletionStream failed for model=${model}`, { + message: error instanceof Error ? error.message : String(error), + }); + throw error; + } + + if (!response.ok) { + clearTimeout(timeout); + const errorBody = await response.text().catch(() => 'unknown'); + throw new Error(`GenericOpenAI API error ${response.status}: ${errorBody}`); + } + + // Buffer all raw text. We only yield clean deltas (post-thinking, no XML tool calls). + let rawBuffer = ''; + let thinkingDone = false; // true once we've seen + let cleanYielded = 0; // how many chars of clean content we've already yielded + let streamUsage: any = null; + + try { + for await (const chunk of parseSseStream(response)) { + // Capture usage from the final chunk + if (chunk?.usage) { + streamUsage = chunk.usage; + } + + const choice = chunk?.choices?.[0]; + const delta = choice?.delta; + + if (delta?.content) { + rawBuffer += delta.content; + + // Phase 1: still in thinking — check if arrived + if (!thinkingDone) { + const thinkEnd = rawBuffer.indexOf(''); + if (thinkEnd !== -1) { + thinkingDone = true; + // Everything after is real content (minus XML tool calls) + const afterThink = rawBuffer.slice(thinkEnd + ''.length); + const clean = stripXmlToolCalls(afterThink); + if (clean.length > 0) { + yield { delta: clean, raw: chunk }; + cleanYielded = clean.length; + } + } + // While still thinking, yield nothing — buffer silently + } else { + // Phase 2: post-thinking — yield only the newly arrived clean content + const afterThink = rawBuffer.slice(rawBuffer.indexOf('') + ''.length); + const clean = stripXmlToolCalls(afterThink); + const newContent = clean.slice(cleanYielded); + if (newContent.length > 0) { + yield { delta: newContent, raw: chunk }; + cleanYielded = clean.length; + } + } + } + + // Native tool call deltas (fallback for models that support them natively) + if (Array.isArray(delta?.tool_calls)) { + for (const toolCall of delta.tool_calls) { + const fn = toolCall.function || {}; + yield { + toolCallDelta: { + index: toolCall.index ?? 0, + id: toolCall.id, + type: 'function' as const, + function: { + name: fn.name, + argumentsDelta: fn.arguments, + }, + }, + raw: chunk, + }; + } + } + + // Stream finished + const finish = choice?.finish_reason; + if (finish) { + // If we never saw , the entire buffer is content (no thinking) + if (!thinkingDone) { + const clean = stripXmlToolCalls(rawBuffer); + const newContent = clean.slice(cleanYielded); + if (newContent.length > 0) { + yield { delta: newContent, raw: chunk }; + } + } + + // Parse XML tool calls from the full buffer and emit as toolCallDeltas + const { content: afterThink } = parseThinkingContent(rawBuffer); + const toolCalls = parseXmlToolCalls(afterThink); + for (let i = 0; i < toolCalls.length; i++) { + const tc = toolCalls[i]; + yield { + toolCallDelta: { + index: i, + id: tc.id, + type: 'function' as const, + function: { + name: tc.function.name, + argumentsDelta: tc.function.arguments, + }, + }, + raw: chunk, + }; + } + + yield { isDone: true, finishReason: finish, usage: streamUsage, raw: chunk }; + } + } + + // Final usage yield if not already sent + if (streamUsage) { + yield { isDone: true, usage: streamUsage }; + } + } finally { + clearTimeout(timeout); + } + }, + }; +}; diff --git a/src/providers/index.ts b/src/providers/index.ts index 3c52c0f..3bd4fcb 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -3,6 +3,7 @@ export * from './model'; export * from './mcp'; +export * from './generic-openai'; export { createAiSdkProvider, type AiSdkFunctionTool, diff --git a/src/providers/model.ts b/src/providers/model.ts index 593171c..f495ef6 100644 --- a/src/providers/model.ts +++ b/src/providers/model.ts @@ -431,7 +431,7 @@ async function buildChatMessageWithAttachments( return base as OpenAI.Chat.Completions.ChatCompletionMessageParam; } -function zodSchemaToJsonSchema(zodSchema: any): any { +export function zodSchemaToJsonSchema(zodSchema: any): any { if (zodSchema._def?.typeName === 'ZodObject') { const properties: Record = {}; const required: string[] = [];