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(' is thinking, content after is response.
+ * Tool call format: value
+ */
+
+import { createHash } from 'crypto';
+import {
+ ModelProvider,
+ Message,
+ ToolCall,
+ CompletionStreamChunk,
+ getTextContent,
+ type RunState,
+ type Agent,
+ type RunConfig,
+} from '../core/types.js';
+import { zodSchemaToJsonSchema } from './model.js';
+import { safeConsole, isVerboseLogging } from '../utils/logger.js';
+
+// ========== Types ==========
+
+interface GenericOpenAIProviderOptions {
+ baseURL?: string;
+ defaultHeaders?: Record;
+ 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[] = [];