├── src ├── translation │ ├── run-tests.js │ ├── tools │ │ └── index.ts │ ├── detection │ │ ├── index.ts │ │ ├── openai.ts │ │ └── ollama.ts │ ├── capabilities │ │ └── index.ts │ ├── utils │ │ ├── providerMapping.ts │ │ ├── contextFactory.ts │ │ ├── typeGuards.ts │ │ └── resultHelpers.ts │ ├── types │ │ ├── index.ts │ │ ├── translator.ts │ │ └── models.ts │ └── index.ts ├── types │ ├── generated │ │ ├── ollama │ │ │ ├── version.ts │ │ │ ├── generate.ts │ │ │ ├── tags.ts │ │ │ ├── chat-stream-chunk.ts │ │ │ ├── index.ts │ │ │ ├── chat.ts │ │ │ └── show.ts │ │ ├── index.ts │ │ └── openai │ │ │ ├── index.ts │ │ │ ├── chat-completion-stream-chunk.ts │ │ │ ├── chat-completion.ts │ │ │ └── models-list.ts │ ├── index.ts │ ├── openai.ts │ └── toolbridge.ts ├── parsers │ └── xml │ │ ├── xmlToolParser.ts │ │ ├── xmlUtils.ts │ │ ├── index.ts │ │ ├── processing │ │ └── HtmlFilter.ts │ │ ├── utils │ │ ├── xmlValueParsing.ts │ │ ├── xmlParsing.ts │ │ ├── xmlCleaning.ts │ │ └── jsonFallback.ts │ │ └── core │ │ ├── WrapperDetector.ts │ │ └── ParameterExtractor.ts ├── server │ ├── index.ts │ └── genericProxy.ts ├── logging │ ├── logger.ts │ ├── index.ts │ ├── configLogger.ts │ └── requestLogger.ts ├── handlers │ ├── stream │ │ ├── base │ │ │ └── index.ts │ │ ├── processors │ │ │ └── index.ts │ │ ├── components │ │ │ ├── index.ts │ │ │ ├── NdjsonFormatter.ts │ │ │ ├── SseFormatter.ts │ │ │ └── StateTracker.ts │ │ └── wrapperAwareStreamProcessor.ts │ ├── toolCallHandler.ts │ ├── ollamaVersionHandler.ts │ ├── openaiModelsHandler.ts │ ├── openaiModelInfoHandler.ts │ ├── ollamaTagsHandler.ts │ └── formatDetector.ts ├── utils │ ├── http │ │ ├── index.ts │ │ ├── sseUtils.ts │ │ ├── streamUtils.ts │ │ ├── proxyLogging.ts │ │ ├── handlerUtils.ts │ │ ├── proxyUtils.ts │ │ ├── httpUtils.ts │ │ └── headerUtils.ts │ └── url │ │ └── index.ts ├── test │ ├── utils │ │ ├── index.ts │ │ ├── ndjsonUtils.ts │ │ ├── sseUtils.ts │ │ ├── run-all-tests-sequential.ts │ │ └── retryHelpers.ts │ ├── parser │ │ ├── edge-cases │ │ │ ├── simpleDuplication.test.ts │ │ │ ├── simpleImport.test.ts │ │ │ ├── textWithToolCall.test.ts │ │ │ └── textDuplication.test.ts │ │ ├── xml │ │ │ ├── advanced.test.ts │ │ │ ├── mutationTesting.test.ts │ │ │ └── debugTool.test.ts │ │ ├── html │ │ │ ├── tagDetection.test.ts │ │ │ ├── inToolCall.test.ts │ │ │ └── contentValidation.test.ts │ │ └── llm-patterns │ │ │ └── fuzzyContent.test.ts │ ├── runners │ │ ├── run-single-test.ts │ │ ├── run-tool-call-tests.ts │ │ ├── run-html-buffer-tests.ts │ │ ├── run-integration-sequential.ts │ │ └── run-llm-pattern-tests.ts │ ├── unit │ │ ├── translation │ │ │ ├── xml_prompt_format.test.ts │ │ │ └── passToolsTransform.test.ts │ │ ├── services │ │ │ └── modelServiceSyntheticShow.test.ts │ │ └── utils │ │ │ └── retryHelpers.test.ts │ ├── quick-server-test.ts │ ├── streaming │ │ ├── errorHandling.test.ts │ │ └── htmlTool.test.ts │ ├── regression │ │ └── html-buffer-overflow.test.ts │ ├── performance │ │ └── memoryUsage.test.ts │ └── challenges │ │ └── generateTests.ts ├── services │ ├── index.ts │ ├── configService.ts │ ├── translationService.ts │ └── contracts.ts └── constants │ └── endpoints.ts ├── .mocharc.js ├── .c8rc.json ├── index.ts ├── .jscpd.json ├── .env.example ├── scripts ├── check-models.js └── run-all-tests.sh ├── LICENSE ├── tsconfig.json ├── .gitignore └── config.json /src/translation/run-tests.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/translation/tools/index.ts: -------------------------------------------------------------------------------- 1 | export * from './promptUtils.js'; 2 | -------------------------------------------------------------------------------- /src/types/generated/ollama/version.ts: -------------------------------------------------------------------------------- 1 | export interface VersionResponse { 2 | version: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/parsers/xml/xmlToolParser.ts: -------------------------------------------------------------------------------- 1 | export { getWrapperTags, hasToolCallWrapper, extractToolCallFromWrapper } from "./toolCallParser.js"; 2 | -------------------------------------------------------------------------------- /src/parsers/xml/xmlUtils.ts: -------------------------------------------------------------------------------- 1 | export { 2 | extractToolCall as extractToolCall, 3 | attemptPartialToolCallExtraction, 4 | } from "./toolCallParser.js"; 5 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | // Server modules are side-effectful entry points. This file exists to satisfy 2 | // path tooling but intentionally exports nothing. 3 | export {}; 4 | -------------------------------------------------------------------------------- /.mocharc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | "node-option": ["loader=esm"], 3 | 4 | spec: ["dist/src/test/**/*.test.js"], 5 | 6 | reporter: "spec", 7 | 8 | timeout: 10000, 9 | 10 | color: true, 11 | 12 | exit: true, 13 | }; 14 | -------------------------------------------------------------------------------- /src/translation/detection/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Translation Detection Module 3 | * 4 | * Format detection for requests and responses. 5 | */ 6 | 7 | export { isOllamaFormat } from './ollama.js'; 8 | export { isOpenAIFormat } from './openai.js'; 9 | -------------------------------------------------------------------------------- /.c8rc.json: -------------------------------------------------------------------------------- 1 | { 2 | "all": true, 3 | "include": ["src/**/*.js"], 4 | "exclude": ["src/test/**"], 5 | "reporter": ["text", "html", "lcov"], 6 | "check-coverage": true, 7 | "statements": 70, 8 | "branches": 60, 9 | "functions": 70, 10 | "lines": 70 11 | } 12 | -------------------------------------------------------------------------------- /src/logging/logger.ts: -------------------------------------------------------------------------------- 1 | import { DEBUG_MODE } from "../config.js"; 2 | 3 | import { createLogger, type Logger } from "./configLogger.js"; 4 | 5 | const logger: Logger = createLogger(DEBUG_MODE); 6 | 7 | export default logger; 8 | 9 | export const { debug, log, error, warn, info } = logger; -------------------------------------------------------------------------------- /src/types/generated/index.ts: -------------------------------------------------------------------------------- 1 | // Auto-generated API types from live endpoints 2 | // SSOT for OpenAI and Ollama API types 3 | // DO NOT EDIT - regenerate with: npm run generate:types 4 | 5 | export type * as OpenAI from './openai/index.js'; 6 | export type * as Ollama from './ollama/index.js'; 7 | -------------------------------------------------------------------------------- /src/handlers/stream/base/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base Stream Processor Module 3 | * 4 | * SSOT for common stream processing functionality 5 | */ 6 | 7 | export { BaseStreamProcessor } from "./BaseStreamProcessor.js"; 8 | export type { BaseStreamState, ToolExtractionResult } from "./BaseStreamProcessor.js"; 9 | -------------------------------------------------------------------------------- /src/translation/capabilities/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Translation Capabilities Module 3 | * 4 | * Provider capability tracking and management. 5 | */ 6 | 7 | export type { ProviderCapabilities } from './capabilitiesMap.js'; 8 | export { CAPABILITIES, filterRequestByCapabilities } from './capabilitiesMap.js'; 9 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Backward-compatibility shim. 3 | * 4 | * Older scripts and tooling referenced the project root `index.ts`. The 5 | * canonical entry point now lives under `src/index.ts`, so we simply import it 6 | * here to keep those legacy references working. 7 | */ 8 | 9 | import "./src/index.js"; 10 | -------------------------------------------------------------------------------- /src/logging/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Logging Module 3 | * 4 | * Centralized logging for the entire application. 5 | * All logging MUST go through this module. 6 | */ 7 | 8 | export { default as logger } from './logger.js'; 9 | export { createLogger as createConfigLogger } from './configLogger.js'; 10 | export { logRequest, logResponse } from './requestLogger.js'; 11 | -------------------------------------------------------------------------------- /src/utils/http/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * HTTP Module 3 | * 4 | * HTTP-related utilities for headers, streaming, and SSE. 5 | * Note: OpenAI-specific chunk creation functions have been moved to 6 | * the translation layer (OpenAIConverter) to maintain SSOT. 7 | */ 8 | 9 | export { buildBackendHeaders } from './headerUtils.js'; 10 | export { formatSSEChunk } from './sseUtils.js'; 11 | export { streamToString } from './streamUtils.js'; 12 | -------------------------------------------------------------------------------- /src/handlers/toolCallHandler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * DEPRECATED: This file now re-exports from the parser layer. 3 | * 4 | * LAYERING FIX: detectPotentialToolCall moved to src/parsers/xml/utils/toolCallDetection.ts 5 | * Handlers should not contain parsing logic - parsers should not import from handlers. 6 | * 7 | * This re-export maintains backward compatibility. 8 | */ 9 | export { detectPotentialToolCall } from "../parsers/xml/utils/toolCallDetection.js"; -------------------------------------------------------------------------------- /src/handlers/stream/processors/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Stream Processors - Format-specific stream processors 3 | * 4 | * SSOT/DRY Compliance: 5 | * - All processors extend BaseStreamProcessor 6 | * - Common functionality in base class 7 | * - Format-specific logic only in subclasses 8 | */ 9 | 10 | export { OllamaLineJSONStreamProcessor } from "./OllamaLineJSONStreamProcessor.js"; 11 | export { OpenAISSEStreamProcessor } from "./OpenAISSEStreamProcessor.js"; 12 | -------------------------------------------------------------------------------- /src/utils/http/sseUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * SSE (Server-Sent Events) Utilities 3 | * 4 | * Pure transport-level SSE formatting functions. 5 | * Provider-specific chunk creation has been moved to translation layer converters. 6 | */ 7 | 8 | /** 9 | * Formats data as an SSE chunk. 10 | * This is a pure transport-level function - not provider-specific. 11 | */ 12 | export function formatSSEChunk(data: unknown): string { 13 | return `data: ${JSON.stringify(data)}\n\n`; 14 | } -------------------------------------------------------------------------------- /.jscpd.json: -------------------------------------------------------------------------------- 1 | { 2 | "threshold": 3, 3 | "reporters": ["html", "console", "json"], 4 | "ignore": [ 5 | "**/*.test.ts", 6 | "**/*.spec.ts", 7 | "**/test/**", 8 | "**/node_modules/**", 9 | "**/dist/**", 10 | "**/dist-test/**", 11 | "**/*.d.ts", 12 | "**/examples/**" 13 | ], 14 | "format": ["typescript"], 15 | "absolute": true, 16 | "gitignore": true, 17 | "mode": "strict", 18 | "minLines": 5, 19 | "minTokens": 50, 20 | "output": "./reports/jscpd" 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/http/streamUtils.ts: -------------------------------------------------------------------------------- 1 | import type { Readable } from "stream"; 2 | 3 | export async function streamToString(stream: Readable): Promise { 4 | const chunks: Buffer[] = []; 5 | return new Promise((resolve, reject) => { 6 | stream.on("data", (chunk: Buffer | string) => { 7 | chunks.push(Buffer.from(chunk)); 8 | }); 9 | stream.on("error", (error: Error) => reject(error)); 10 | stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); 11 | }); 12 | } -------------------------------------------------------------------------------- /src/translation/detection/openai.ts: -------------------------------------------------------------------------------- 1 | export function isOpenAIFormat(obj: unknown): obj is Record { 2 | if (obj !== null && typeof obj === "object") { 3 | const record = obj as Record; 4 | 5 | if (Array.isArray(record["messages"]) || Array.isArray(record["choices"])) { 6 | return true; 7 | } 8 | 9 | if (typeof record["object"] === "string" && record["object"] === "chat.completion.chunk" && Array.isArray(record["choices"])) { 10 | return true; 11 | } 12 | } 13 | return false; 14 | } 15 | -------------------------------------------------------------------------------- /src/types/generated/ollama/generate.ts: -------------------------------------------------------------------------------- 1 | export interface GenerateResponse { 2 | model: string; 3 | created_at: string; 4 | response: string; 5 | thinking: string; 6 | done: boolean; 7 | done_reason: string; 8 | context: number[]; 9 | total_duration: number; 10 | load_duration: number; 11 | prompt_eval_count: number; 12 | prompt_eval_duration: number; 13 | eval_count: number; 14 | eval_duration: number; 15 | } 16 | -------------------------------------------------------------------------------- /src/types/generated/ollama/tags.ts: -------------------------------------------------------------------------------- 1 | export interface TagsResponse { 2 | models: Model[]; 3 | } 4 | 5 | export interface Model { 6 | name: string; 7 | model: string; 8 | modified_at: string; 9 | size: number; 10 | digest: string; 11 | details: Details; 12 | } 13 | 14 | export interface Details { 15 | parent_model?: string | undefined; 16 | format: string; 17 | family: string; 18 | families: string[]; 19 | parameter_size: string; 20 | quantization_level: string; 21 | } 22 | -------------------------------------------------------------------------------- /src/parsers/xml/index.ts: -------------------------------------------------------------------------------- 1 | export type { ExtractedToolCall } from "../../types/index.js"; 2 | 3 | export { 4 | getWrapperTags, 5 | hasToolCallWrapper, 6 | extractToolCallFromWrapper, 7 | extractToolCallsFromWrapper, 8 | extractToolCall, 9 | attemptPartialToolCallExtraction, 10 | } from "./toolCallParser.js"; 11 | 12 | export { detectPotentialToolCall } from "./utils/toolCallDetection.js"; 13 | 14 | // SSOT: Unified extraction functions - prefer these over wrapper-only or direct-only extraction 15 | export { 16 | extractToolCallUnified, 17 | extractToolCallsUnified, 18 | } from "./utils/unifiedToolExtraction.js"; 19 | -------------------------------------------------------------------------------- /src/test/utils/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Test utilities barrel export 3 | * SSOT for all test helper imports 4 | */ 5 | 6 | // Server lifecycle and setup 7 | export * from './serverLifecycle.js'; 8 | export * from './testServerHelpers.js'; 9 | export * from './portManager.js'; 10 | 11 | // Configuration 12 | export * from './testConfig.js'; 13 | export * from './testConfigLoader.js'; 14 | 15 | // Stream utilities 16 | export * from './sseUtils.js'; 17 | export * from './ndjsonUtils.js'; 18 | 19 | // HTTP utilities 20 | export * from './retryHelpers.js'; 21 | 22 | // Test helpers and mocks 23 | export * from './testHelpers.js'; 24 | -------------------------------------------------------------------------------- /src/types/generated/ollama/chat-stream-chunk.ts: -------------------------------------------------------------------------------- 1 | export interface ChatStreamChunk { 2 | model: string; 3 | created_at: string; 4 | message: Message; 5 | done: boolean; 6 | done_reason?: string; 7 | total_duration?: number; 8 | load_duration?: number; 9 | prompt_eval_count?: number; 10 | prompt_eval_duration?: number; 11 | eval_count?: number; 12 | eval_duration?: number; 13 | } 14 | 15 | export interface Message { 16 | role: string; 17 | content: string; 18 | thinking?: string; 19 | } 20 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Service Layer Exports 3 | * 4 | * Central export point for all services. Handlers import from here ONLY. 5 | */ 6 | 7 | export { translationService } from './translationService.js'; 8 | export { configService } from './configService.js'; 9 | export { formatDetectionService } from './formatDetectionService.js'; 10 | export { backendService } from './backendService.js'; 11 | export { modelService } from './model/index.js'; 12 | 13 | export type { 14 | RequestContext, 15 | TranslationService, 16 | BackendService, 17 | ConfigService, 18 | FormatDetectionService, 19 | ModelService, 20 | } from './contracts.js'; 21 | -------------------------------------------------------------------------------- /src/types/generated/ollama/index.ts: -------------------------------------------------------------------------------- 1 | // Auto-generated Ollama API types from live endpoints 2 | // DO NOT EDIT - regenerate with: npm run generate:types 3 | // 4 | // Types generated from multiple API response variations to ensure optional fields are correctly inferred 5 | 6 | export type { TagsResponse, Model } from './tags.js'; 7 | export type { ShowResponse } from './show.js'; 8 | export type { 9 | ChatResponse, 10 | Message, 11 | ToolCall, 12 | Function 13 | } from './chat.js'; 14 | export type { GenerateResponse } from './generate.js'; 15 | export type { ChatStreamChunk } from './chat-stream-chunk.js'; 16 | export type { VersionResponse } from './version.js'; 17 | -------------------------------------------------------------------------------- /src/translation/utils/providerMapping.ts: -------------------------------------------------------------------------------- 1 | import type { RequestFormat } from "../../types/toolbridge.js"; 2 | import type { LLMProvider } from "../types/index.js"; 3 | 4 | export function formatToProvider(format: RequestFormat): LLMProvider { 5 | switch (format) { 6 | case "openai": 7 | return "openai"; 8 | case "ollama": 9 | return "ollama"; 10 | default: 11 | return "openai"; 12 | } 13 | } 14 | 15 | export function providerToFormat(provider: LLMProvider): RequestFormat { 16 | switch (provider) { 17 | case "openai": 18 | return "openai"; 19 | case "ollama": 20 | return "ollama"; 21 | default: 22 | return "openai"; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/translation/detection/ollama.ts: -------------------------------------------------------------------------------- 1 | export function isOllamaFormat(obj: unknown): obj is Record { 2 | if (!obj || typeof obj !== "object") { return false; } 3 | const record = obj as Record; 4 | 5 | if (typeof record["prompt"] === "string" || typeof record["response"] === "string" || typeof record["done"] === "boolean") { 6 | return true; 7 | } 8 | 9 | if ( 10 | typeof record["model"] === "string" 11 | && (typeof record["created_at"] === "number" || typeof record["created_at"] === "string") 12 | && (typeof record["response"] === "string" || typeof record["done"] === "boolean") 13 | ) { 14 | return true; 15 | } 16 | 17 | return false; 18 | } 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ---------- OpenAI Compatible ---------- 2 | # API Key for OpenAI or compatible provider (e.g., OpenRouter, LocalAI) 3 | # If using Ollama locally, this key is often not required or ignored. 4 | BACKEND_LLM_API_KEY=your_api_key_here 5 | 6 | # ---------- Test Configuration ---------- 7 | # Set to true if you want to run integration tests against a real backend. 8 | # WARNING: This may incur costs if using a paid API. 9 | RUN_REAL_BACKEND_TESTS=false 10 | 11 | # ---------- Ollama ---------- 12 | # URL for your local Ollama instance (default: http://localhost:11434) 13 | # If you are running Ollama on a different host/port, update this in config.json 14 | # or handle it via proxy settings. 15 | # OLLAMA_URL=http://localhost:11434 16 | -------------------------------------------------------------------------------- /src/types/generated/openai/index.ts: -------------------------------------------------------------------------------- 1 | // Auto-generated OpenAI API types from live endpoints 2 | // DO NOT EDIT - regenerate with: npm run generate:types 3 | // 4 | // Types generated from multiple API response variations to ensure optional fields are correctly inferred 5 | 6 | export type { ModelsListResponse, Datum as Model } from './models-list.js'; 7 | export type { 8 | ChatCompletionResponse, 9 | Choice, 10 | Message, 11 | ToolCall, 12 | Function as ToolFunction, 13 | ReasoningDetail, 14 | Usage, 15 | CompletionTokensDetails 16 | } from './chat-completion.js'; 17 | export type { 18 | ChatCompletionStreamChunk, 19 | Choice as StreamChoice, 20 | Delta, 21 | Usage as StreamUsage 22 | } from './chat-completion-stream-chunk.js'; 23 | -------------------------------------------------------------------------------- /src/handlers/stream/components/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Stream Components - Reusable Stream Processing Components 3 | * 4 | * SSOT/DRY/KISS Compliant Components: 5 | * - Each component has a SINGLE responsibility 6 | * - All components are <150 lines 7 | * - No duplication across components 8 | * - Clear interfaces and contracts 9 | */ 10 | 11 | export { BufferManager } from "./BufferManager.js"; 12 | export { XmlDetector } from "./XmlDetector.js"; 13 | export type { XmlDetectionResult, XmlExtractionResult } from "./XmlDetector.js"; 14 | export { SseFormatter } from "./SseFormatter.js"; 15 | export { NdjsonFormatter } from "./NdjsonFormatter.js"; 16 | export { StateTracker } from "./StateTracker.js"; 17 | export type { StreamState } from "./StateTracker.js"; 18 | -------------------------------------------------------------------------------- /src/handlers/ollamaVersionHandler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ollama /api/version Handler 3 | * 4 | * Returns a synthetic version response when backend is not Ollama. 5 | * Prevents errors when clients check the version endpoint. 6 | */ 7 | 8 | import { logger } from '../logging/index.js'; 9 | 10 | import type { Request, Response } from 'express'; 11 | 12 | /** 13 | * Handler for /api/version endpoint 14 | * Returns synthetic version when backend doesn't support this endpoint 15 | */ 16 | export default async function ollamaVersionHandler(_req: Request, res: Response): Promise { 17 | logger.debug(`[OLLAMA VERSION] Returning synthetic version (backend doesn't support /api/version)`); 18 | 19 | res.status(200).json({ 20 | version: "0.12.6", 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/test/parser/edge-cases/simpleDuplication.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { beforeEach, describe, it } from "mocha"; 3 | 4 | interface MockResponse { 5 | write: (chunk: string) => void; 6 | } 7 | 8 | describe("Simple Duplication Test", function () { 9 | let capturedChunks: string[]; 10 | 11 | beforeEach(function () { 12 | capturedChunks = []; 13 | }); 14 | 15 | it("should properly capture chunks", function () { 16 | const mockResponse: MockResponse = { 17 | write: (chunk: string) => { 18 | capturedChunks.push(chunk); 19 | }, 20 | }; 21 | 22 | expect(capturedChunks.length).to.equal(0); 23 | mockResponse.write("test chunk"); 24 | expect(capturedChunks.length).to.equal(1); 25 | expect(capturedChunks[0]).to.equal("test chunk"); 26 | }); 27 | }); -------------------------------------------------------------------------------- /src/test/parser/edge-cases/simpleImport.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { describe, it } from "mocha"; 3 | 4 | import { detectPotentialToolCall } from "../../../handlers/toolCallHandler.js"; 5 | import { extractToolCall } from "../../../parsers/xml/index.js"; 6 | 7 | import type { ToolCallDetectionResult } from "../../../types/index.js"; 8 | 9 | describe("Import Verification Tests", function () { 10 | it("should verify that core utility imports work correctly", function () { 11 | expect(extractToolCall).to.be.a("function"); 12 | expect(detectPotentialToolCall).to.be.a("function"); 13 | 14 | const result: ToolCallDetectionResult = detectPotentialToolCall( 15 | "test", 16 | ["search"], 17 | ); 18 | expect(result).to.not.be.null; 19 | expect(result.rootTagName).to.equal("search"); 20 | }); 21 | }); -------------------------------------------------------------------------------- /src/types/generated/ollama/chat.ts: -------------------------------------------------------------------------------- 1 | export interface ChatResponse { 2 | model: string; 3 | created_at: string; 4 | message: Message; 5 | done: boolean; 6 | done_reason: string; 7 | total_duration: number; 8 | load_duration: number; 9 | prompt_eval_count: number; 10 | prompt_eval_duration: number; 11 | eval_count: number; 12 | eval_duration: number; 13 | } 14 | 15 | export interface Message { 16 | role: string; 17 | content: string; 18 | thinking: string; 19 | tool_calls?: ToolCall[]; 20 | } 21 | 22 | export interface ToolCall { 23 | id: string; 24 | function: Function; 25 | } 26 | 27 | export interface Function { 28 | index: number; 29 | name: string; 30 | arguments: Record; 31 | } 32 | 33 | export type Arguments = Record; 34 | -------------------------------------------------------------------------------- /src/test/runners/run-single-test.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | import * as path from "path"; 3 | 4 | const testDir = path.join(process.cwd(), "src", "test"); 5 | 6 | const args: string[] = process.argv.slice(2); 7 | if (args.length === 0) { 8 | console.error( 9 | "Please provide a test file path relative to the src/test directory.", 10 | ); 11 | console.error( 12 | "Example: node run-single-test.js parser/llm-patterns/fuzzyContent.test.js", 13 | ); 14 | process.exit(1); 15 | } 16 | 17 | const relativePath: string = args[0] || ''; 18 | const fullPath = path.join(testDir, relativePath); 19 | 20 | console.log(`=== Running specific test: ${relativePath} ===`); 21 | 22 | try { 23 | const output = execSync(`node ${fullPath}`, { 24 | encoding: "utf-8", 25 | stdio: "inherit", 26 | }); 27 | 28 | console.log(output); 29 | process.exit(0); 30 | } catch (error: unknown) { 31 | const err = error instanceof Error ? error : new Error(String(error)); 32 | console.error(`Error running test: ${err.message}`); 33 | process.exit(1); 34 | } -------------------------------------------------------------------------------- /scripts/check-models.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Check available models from Deepinfra via ToolBridge 4 | 5 | import http from "http"; 6 | 7 | const options = { 8 | hostname: "localhost", 9 | port: 3100, 10 | path: "/v1/models", 11 | method: "GET", 12 | headers: { 13 | Authorization: "Bearer test-key", 14 | }, 15 | }; 16 | 17 | process.stdout.write("🔍 Checking available models...\n"); 18 | 19 | const req = http.request(options, (res) => { 20 | process.stdout.write(`Response Status: ${res.statusCode}\n`); 21 | 22 | let data = ""; 23 | res.on("data", (chunk) => { 24 | data += chunk; 25 | }); 26 | 27 | res.on("end", () => { 28 | try { 29 | const response = JSON.parse(data); 30 | process.stdout.write(`Available models:\n${JSON.stringify(response, null, 2)}\n`); 31 | } catch (_err) { 32 | process.stdout.write(`Raw response: ${data}\n`); 33 | } 34 | }); 35 | }); 36 | 37 | req.on("error", (err) => { 38 | process.stderr.write(`Request failed: ${err && err.message ? err.message : String(err)}\n`); 39 | }); 40 | 41 | req.end(); 42 | -------------------------------------------------------------------------------- /src/test/utils/ndjsonUtils.ts: -------------------------------------------------------------------------------- 1 | export async function readNdjsonStream(response: Response): Promise<{ lines: string[]; done: boolean }> 2 | { 3 | const reader = (response.body as unknown as ReadableStream | null)?.getReader(); 4 | if (!reader) { 5 | return { lines: [], done: false }; 6 | } 7 | const decoder = new TextDecoder(); 8 | const lines: string[] = []; 9 | let doneFlag = false; 10 | try { 11 | for (;;) { 12 | const { done, value } = await reader.read(); 13 | if (done) { 14 | break; 15 | } 16 | const text = decoder.decode(value, { stream: true }); 17 | const parsedLines = text.split("\n").filter(line => line.trim()); 18 | for (const line of parsedLines) { 19 | lines.push(line); 20 | try { 21 | const json = JSON.parse(line); 22 | if (json && json.done === true) { 23 | doneFlag = true; 24 | } 25 | } catch { 26 | // ignore 27 | } 28 | } 29 | } 30 | } finally { 31 | reader.releaseLock(); 32 | } 33 | return { lines, done: doneFlag }; 34 | } 35 | -------------------------------------------------------------------------------- /src/parsers/xml/processing/HtmlFilter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * HTML tag detection and filtering 3 | * Extracted from toolCallParser.ts for KISS compliance 4 | * 5 | * LLMs sometimes start responses with HTML tags before tool calls. 6 | * This module detects and handles that case. 7 | */ 8 | 9 | const COMMON_HTML_TAGS = [ 10 | "div", "span", "p", "h1", "h2", "h3", "h4", "h5", "h6", 11 | "style", "script", "link", "meta", "title", "head", "body", "html", 12 | "form", "input", "button", "textarea", "select", "option", 13 | ]; 14 | 15 | /** 16 | * Regex to detect if content starts with a common HTML tag 17 | */ 18 | export const htmlStartRegex = new RegExp(`^\\s*<(${COMMON_HTML_TAGS.join("|")})\\b`, "i"); 19 | 20 | /** 21 | * Check if text starts with a common HTML tag 22 | */ 23 | export const startsWithHtmlTag = (text: string): boolean => { 24 | return htmlStartRegex.test(text); 25 | }; 26 | 27 | /** 28 | * Get the HTML tag name if text starts with one 29 | */ 30 | export const getHtmlTagName = (text: string): string | null => { 31 | const match = text.match(htmlStartRegex); 32 | return match?.[1] ?? null; 33 | }; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Oct4Pie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/translation/types/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Translation Types - Main exports 3 | * 4 | * Central export point for all translation-related types 5 | */ 6 | 7 | // Re-export all generic types 8 | export * from './generic.js'; 9 | 10 | // Re-export all provider-specific types 11 | export * from './providers.js'; 12 | 13 | // Convenience type exports 14 | export type { 15 | // Core types 16 | LLMProvider, 17 | GenericLLMRequest, 18 | GenericLLMResponse, 19 | GenericStreamChunk, 20 | GenericMessage, 21 | GenericTool, 22 | GenericToolCall, 23 | 24 | // Compatibility types 25 | CompatibilityResult, 26 | ConversionContext, 27 | 28 | // Capabilities 29 | ProviderCapabilities, 30 | 31 | // Error types 32 | TranslationError, 33 | UnsupportedFeatureError, 34 | } from './generic.js'; 35 | 36 | export type { 37 | // Provider configs 38 | OpenAIProviderConfig, 39 | OllamaProviderConfig, 40 | ProviderConfig, 41 | 42 | // Mappings 43 | ParameterMapping, 44 | ModelMapping, 45 | FeatureCompatibility, 46 | 47 | // Registry 48 | ProviderRegistry, 49 | EndpointPattern, 50 | } from './providers.js'; 51 | -------------------------------------------------------------------------------- /src/handlers/openaiModelsHandler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * OpenAI /v1/models Handler 3 | * 4 | * Backend-agnostic model listing with automatic format translation. 5 | * Returns OpenAI-formatted model lists regardless of the backend provider. 6 | */ 7 | 8 | import { logger } from '../logging/index.js'; 9 | import { modelService } from '../services/index.js'; 10 | import { sendHTTPError } from '../utils/http/errorResponseHandler.js'; 11 | import { getBackendContext, sendSuccessJSON } from '../utils/http/handlerUtils.js'; 12 | 13 | import type { ModelsListResponse } from '../types/generated/openai/models-list.js'; 14 | import type { Request, Response } from 'express'; 15 | 16 | export default async function openaiModelsHandler(req: Request, res: Response): Promise { 17 | try { 18 | const { backendMode, authHeader } = getBackendContext(req); 19 | 20 | logger.info(`[OPENAI MODELS] Listing models for backend=${backendMode}`); 21 | 22 | const response = await modelService.listModels('openai', authHeader) as ModelsListResponse; 23 | 24 | sendSuccessJSON(res, response, 'OPENAI MODELS'); 25 | } catch (error: unknown) { 26 | sendHTTPError(res, error, 'OPENAI MODELS'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/test/runners/run-tool-call-tests.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { fileURLToPath } from "url"; 4 | 5 | import Mocha from "mocha"; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = path.dirname(__filename); 9 | const testDir = path.resolve(__dirname, ".."); 10 | 11 | const mocha = new Mocha({ 12 | timeout: 10000, 13 | reporter: "spec", 14 | }); 15 | 16 | const testPatterns: string[] = [ 17 | "unit/handlers/toolCallHandler.test.ts", 18 | "unit/utils/xmlUtils.test.ts", 19 | 20 | "integration/toolCallStreaming.test.ts", 21 | "integration/htmlTool.test.ts", 22 | 23 | "parser/tool-calls/edgeCases.test.ts", 24 | "parser/tool-calls/regression.test.ts", 25 | ]; 26 | 27 | testPatterns.forEach((pattern: string) => { 28 | const fullPath = path.join(testDir, pattern); 29 | if (fs.existsSync(fullPath)) { 30 | mocha.addFile(fullPath); 31 | console.log(`Added test file: ${pattern}`); 32 | } else { 33 | console.warn(`Warning: Test file not found: ${pattern}`); 34 | } 35 | }); 36 | 37 | console.log("Running tool call tests..."); 38 | mocha.run((failures: number) => { 39 | process.exitCode = failures ? 1 : 0; 40 | }); -------------------------------------------------------------------------------- /src/test/runners/run-html-buffer-tests.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | import path from "path"; 3 | import { fileURLToPath } from "url"; 4 | 5 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 6 | 7 | const testFiles: string[] = [ 8 | "../streaming/html-with-tool-calls.test.ts", 9 | "../regression/html-buffer-overflow.test.ts", 10 | "../unit/handlers/html-tag-detection.test.ts", 11 | ]; 12 | 13 | console.log("Running HTML buffer handling tests..."); 14 | 15 | try { 16 | for (const file of testFiles) { 17 | const filePath = path.resolve(__dirname, file); 18 | console.log(`\n---- Running tests in ${file} ----`); 19 | 20 | try { 21 | execSync(`npx mocha ${filePath} --experimental-modules`, { 22 | stdio: "inherit", 23 | }); 24 | console.log(`✅ Tests passed in ${file}`); 25 | } catch (_err: unknown) { 26 | console.error(`❌ Tests failed in ${file}`); 27 | process.exit(1); 28 | } 29 | } 30 | 31 | console.log("\n✅ All HTML buffer handling tests passed!"); 32 | } catch (error: unknown) { 33 | const errorMessage = error instanceof Error ? error.message : String(error); 34 | console.error("Error running tests:", errorMessage); 35 | process.exit(1); 36 | } -------------------------------------------------------------------------------- /src/test/utils/sseUtils.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAIStreamChunk } from "../../types/openai.js"; 2 | 3 | export async function readSSEBody(response: Response): Promise { 4 | const reader = (response.body as unknown as ReadableStream | null)?.getReader(); 5 | if (!reader) { 6 | return ""; 7 | } 8 | const decoder = new TextDecoder(); 9 | const chunks: string[] = []; 10 | for (;;) { 11 | const { value, done } = await reader.read(); 12 | if (done) { break; } 13 | if (value) { chunks.push(decoder.decode(value, { stream: true })); } 14 | } 15 | return chunks.join(""); 16 | } 17 | 18 | export function getSSEDataLines(fullText: string): string[] { 19 | return fullText.split("\n").filter(line => line.startsWith("data: ")); 20 | } 21 | 22 | export function parseSSEChunks(fullText: string): OpenAIStreamChunk[] { 23 | const dataLines = getSSEDataLines(fullText); 24 | const chunks: OpenAIStreamChunk[] = []; 25 | for (const line of dataLines) { 26 | const payload = line.substring(6); 27 | if (payload === "[DONE]") { continue; } 28 | try { 29 | const parsed = JSON.parse(payload) as OpenAIStreamChunk; 30 | chunks.push(parsed); 31 | } catch { /* ignore */ } 32 | } 33 | return chunks; 34 | } 35 | 36 | -------------------------------------------------------------------------------- /src/handlers/openaiModelInfoHandler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * OpenAI /v1/models/:model Handler 3 | * 4 | * Returns OpenAI-formatted model metadata for any backend provider. 5 | */ 6 | 7 | import { logger } from '../logging/index.js'; 8 | import { modelService } from '../services/index.js'; 9 | import { sendHTTPError, sendValidationError } from '../utils/http/errorResponseHandler.js'; 10 | import { getBackendContext, sendSuccessJSON } from '../utils/http/handlerUtils.js'; 11 | 12 | import type { Request, Response } from 'express'; 13 | 14 | export default async function openaiModelInfoHandler(req: Request, res: Response): Promise { 15 | const modelId = req.params['model'] ?? req.params['modelId']; 16 | 17 | if (!modelId) { 18 | sendValidationError(res, 'Model identifier is required', 'OPENAI MODEL INFO'); 19 | return; 20 | } 21 | 22 | try { 23 | const { backendMode, authHeader } = getBackendContext(req); 24 | 25 | logger.info(`[OPENAI MODEL INFO] Fetching model="${modelId}" for backend=${backendMode}`); 26 | 27 | const response = await modelService.getModelInfo(modelId, 'openai', authHeader); 28 | 29 | sendSuccessJSON(res, response, 'OPENAI MODEL INFO'); 30 | } catch (error: unknown) { 31 | sendHTTPError(res, error, 'OPENAI MODEL INFO'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /scripts/run-all-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "" 5 | echo "╔═══════════════════════════════════════════════════════════════╗" 6 | echo "║ 🧪 TOOLBRIDGE COMPREHENSIVE TEST SUITE ║" 7 | echo "║ Real Backends • No Mock Servers • All Edge Cases ║" 8 | echo "╚═══════════════════════════════════════════════════════════════╝" 9 | echo "" 10 | 11 | # Load environment 12 | if [ -f .env ]; then 13 | source .env 14 | echo "✅ Loaded environment from .env" 15 | else 16 | echo "⚠️ No .env file - using defaults" 17 | fi 18 | 19 | # Build 20 | echo "" 21 | echo "📦 Building TypeScript..." 22 | npm run build > /dev/null 2>&1 23 | echo "✅ Build complete" 24 | 25 | # Run tests 26 | echo "" 27 | echo "🚀 Running comprehensive test suite..." 28 | echo "" 29 | 30 | npx mocha \ 31 | "dist/src/test/integration/comprehensive-tool-calling.test.js" \ 32 | "dist/src/test/integration/brutality.test.js" \ 33 | --reporter spec \ 34 | --timeout 180000 35 | 36 | echo "" 37 | echo "╔═══════════════════════════════════════════════════════════════╗" 38 | echo "║ ✅ TEST SUITE COMPLETE ║" 39 | echo "║ All tests running against REAL backends ║" 40 | echo "╚═══════════════════════════════════════════════════════════════╝" 41 | -------------------------------------------------------------------------------- /src/translation/utils/contextFactory.ts: -------------------------------------------------------------------------------- 1 | import type { ConversionContext, LLMProvider } from "../types/index.js"; 2 | 3 | export function createConversionContext( 4 | from: LLMProvider, 5 | to: LLMProvider, 6 | partial: Partial = {} 7 | ): ConversionContext { 8 | const knownToolNames = Array.isArray(partial.knownToolNames) 9 | ? partial.knownToolNames.filter((name): name is string => Boolean(name)) 10 | : []; 11 | 12 | const enableXML = 13 | typeof partial.enableXMLToolParsing === "boolean" 14 | ? partial.enableXMLToolParsing 15 | : knownToolNames.length > 0; 16 | 17 | const context: ConversionContext = { 18 | sourceProvider: from, 19 | targetProvider: to, 20 | requestId: partial.requestId ?? Math.random().toString(36).slice(2, 11), 21 | preserveExtensions: partial.preserveExtensions ?? true, 22 | strictMode: partial.strictMode ?? false, 23 | knownToolNames, 24 | enableXMLToolParsing: enableXML, 25 | transformationLog: Array.isArray(partial.transformationLog) ? partial.transformationLog : [], 26 | }; 27 | 28 | if (typeof partial.passTools === 'boolean') { 29 | context.passTools = partial.passTools; 30 | } 31 | 32 | if (partial.toolReinjection !== undefined) { 33 | context.toolReinjection = partial.toolReinjection; 34 | } 35 | 36 | return context; 37 | } 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "node16", 6 | "allowSyntheticDefaultImports": true, 7 | "esModuleInterop": true, 8 | "allowJs": true, 9 | "checkJs": false, 10 | "outDir": "./dist", 11 | "rootDir": "./", 12 | "strict": true, 13 | "noImplicitAny": true, 14 | "strictNullChecks": true, 15 | "strictFunctionTypes": true, 16 | "noImplicitReturns": true, 17 | "noImplicitThis": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "exactOptionalPropertyTypes": true, 21 | "noUncheckedIndexedAccess": true, 22 | "noImplicitOverride": true, 23 | "useUnknownInCatchVariables": true, 24 | "noPropertyAccessFromIndexSignature": true, 25 | "declaration": true, 26 | "declarationMap": true, 27 | "sourceMap": true, 28 | "forceConsistentCasingInFileNames": true, 29 | "skipLibCheck": true, 30 | "resolveJsonModule": true, 31 | "isolatedModules": true, 32 | "incremental": true, 33 | "tsBuildInfoFile": "./dist/.tsbuildinfo" 34 | }, 35 | "include": [ 36 | "index.ts", 37 | "src/**/*", 38 | "test/**/*", 39 | "src/test/**/*", 40 | "test-servers/**/*", 41 | "scripts/manual/**/*.ts" 42 | ], 43 | "exclude": [ 44 | "node_modules", 45 | "dist", 46 | ], 47 | "ts-node": { 48 | "esm": true, 49 | "experimentalSpecifierResolution": "node" 50 | } 51 | } -------------------------------------------------------------------------------- /src/test/parser/xml/advanced.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | 3 | import { after, describe, it } from "mocha"; 4 | 5 | import { extractToolCall } from "../../../parsers/xml/index.js"; 6 | 7 | import type { ExtractedToolCall } from "../../../types/index.js"; 8 | 9 | describe("Advanced XML Tests", function () { 10 | const _knownToolNames: string[] = [ 11 | "insert_edit_into_file", 12 | "create_file", 13 | "search", 14 | "get_files", 15 | "ls", 16 | ]; 17 | 18 | let passCount = 0; 19 | let totalTests = 0; 20 | 21 | function testParser(name: string, content: string, shouldParse: boolean, _expectedToolName: string | null = null): void { 22 | it(`should ${shouldParse ? "parse" : "reject"} ${name}`, function () { 23 | const parsed: ExtractedToolCall | null = extractToolCall(content, _knownToolNames); 24 | 25 | if (shouldParse) { 26 | assert.ok(parsed, `Expected ${name} to parse successfully`); 27 | passCount++; 28 | } else { 29 | assert.ok(!parsed, `Expected ${name} to be rejected`); 30 | passCount++; 31 | } 32 | 33 | totalTests++; 34 | }); 35 | } 36 | 37 | testParser( 38 | "basic valid XML", 39 | "test", 40 | true, 41 | ); 42 | 43 | after(function () { 44 | console.log( 45 | `SUMMARY: ${passCount}/${totalTests} tests passed (${Math.round((passCount / totalTests) * 100)}%)`, 46 | ); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/translation/types/translator.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Translation Engine Types 3 | * 4 | * Type definitions for the translation engine interfaces. 5 | */ 6 | 7 | import type { TranslationError } from './generic.js'; 8 | import type { 9 | LLMProvider, 10 | ConversionContext, 11 | CompatibilityResult, 12 | } from './index.js'; 13 | 14 | /** 15 | * Translation request options 16 | */ 17 | export interface TranslationOptions { 18 | from: LLMProvider; 19 | to: LLMProvider; 20 | request: unknown; 21 | context?: Partial; 22 | strict?: boolean; // Fail on unsupported features vs graceful degradation 23 | preserveExtensions?: boolean; 24 | } 25 | 26 | /** 27 | * Translation result 28 | */ 29 | export interface TranslationResult { 30 | success: boolean; 31 | data?: unknown; 32 | error?: TranslationError; 33 | compatibility: CompatibilityResult; 34 | context: ConversionContext; 35 | transformations: Array<{ 36 | step: string; 37 | description: string; 38 | timestamp: number; 39 | }>; 40 | } 41 | 42 | /** 43 | * Streaming translation options 44 | */ 45 | export interface StreamTranslationOptions extends TranslationOptions { 46 | sourceStream: ReadableStream; 47 | } 48 | 49 | /** 50 | * Stream translation result 51 | */ 52 | export interface StreamTranslationResult { 53 | success: boolean; 54 | stream?: ReadableStream; 55 | error?: TranslationError; 56 | compatibility: CompatibilityResult; 57 | context: ConversionContext; 58 | } 59 | -------------------------------------------------------------------------------- /src/utils/url/index.ts: -------------------------------------------------------------------------------- 1 | import { configService } from "../../services/configService.js"; 2 | 3 | const normalizeBase = (base: string): string => base.replace(/\/+$/, ""); 4 | 5 | const normalizePath = (path?: string): string => { 6 | if (!path) { 7 | return ""; 8 | } 9 | return path.startsWith("/") ? path : `/${path}`; 10 | }; 11 | 12 | export const getProxyBaseUrl = (): string => { 13 | const host = configService.getProxyHost(); 14 | const port = configService.getProxyPort(); 15 | return `http://${host}:${port}`; 16 | }; 17 | 18 | export const buildProxyUrl = (path?: string): string => { 19 | return `${normalizeBase(getProxyBaseUrl())}${normalizePath(path)}`; 20 | }; 21 | 22 | export const getBackendBaseUrl = (): string => { 23 | return configService.getBackendUrl(); 24 | }; 25 | 26 | export const buildBackendUrl = (path?: string): string => { 27 | return `${normalizeBase(getBackendBaseUrl())}${normalizePath(path)}`; 28 | }; 29 | 30 | export const getOpenAIBackendBaseUrl = (): string => { 31 | return configService.getOpenAIBackendUrl(); 32 | }; 33 | 34 | export const buildOpenAIBackendUrl = (path?: string): string => { 35 | return `${normalizeBase(getOpenAIBackendBaseUrl())}${normalizePath(path)}`; 36 | }; 37 | 38 | export const getOllamaBackendBaseUrl = (): string => { 39 | return configService.getOllamaBackendUrl(); 40 | }; 41 | 42 | export const buildOllamaBackendUrl = (path?: string): string => { 43 | return `${normalizeBase(getOllamaBackendBaseUrl())}${normalizePath(path)}`; 44 | }; 45 | -------------------------------------------------------------------------------- /src/test/unit/translation/xml_prompt_format.test.ts: -------------------------------------------------------------------------------- 1 | 2 | import { expect } from 'chai'; 3 | import { formatToolsForBackendPromptXML } from '../../../translation/tools/promptUtils.js'; 4 | import type { OpenAITool } from '../../../types/index.js'; 5 | 6 | describe("Prompt Generation Manual Check", () => { 7 | it("generates correct XML prompt", () => { 8 | const mockTools: OpenAITool[] = [ 9 | { 10 | type: 'function', 11 | function: { 12 | name: 'get_weather', 13 | description: 'Get current weather', 14 | parameters: { 15 | type: 'object', 16 | properties: { 17 | location: { 18 | type: 'string', 19 | description: 'City' 20 | } 21 | }, 22 | required: ['location'] 23 | } 24 | } 25 | } 26 | ]; 27 | 28 | const prompt = formatToolsForBackendPromptXML(mockTools); 29 | console.log("\n--- GENERATED PROMPT ---\n"); 30 | console.log(prompt); 31 | console.log("\n------------------------\n"); 32 | 33 | expect(prompt).to.include(""); 34 | expect(prompt).to.include(""); 35 | expect(prompt).to.include(""); 36 | expect(prompt).to.include('name="location"'); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/logging/configLogger.ts: -------------------------------------------------------------------------------- 1 | export interface Logger { 2 | debug: (...args: unknown[]) => void; 3 | log: (...args: unknown[]) => void; 4 | error: (...args: unknown[]) => void; 5 | warn: (...args: unknown[]) => void; 6 | info: (...args: unknown[]) => void; 7 | } 8 | 9 | export function debug(debugMode: boolean, ...args: unknown[]): void { 10 | if (debugMode) { 11 | try { 12 | process.stdout.write(args.map(a => String(a)).join(' ') + '\n'); 13 | } catch { 14 | // fallback to noop 15 | } 16 | } 17 | } 18 | 19 | export function error(...args: unknown[]): void { 20 | try { 21 | process.stderr.write(args.map(a => String(a)).join(' ') + '\n'); 22 | } catch { 23 | // fallback to noop 24 | } 25 | } 26 | 27 | export function warn(...args: unknown[]): void { 28 | try { 29 | process.stderr.write(args.map(a => String(a)).join(' ') + '\n'); 30 | } catch { 31 | // fallback to noop 32 | } 33 | } 34 | 35 | export function info(...args: unknown[]): void { 36 | try { 37 | process.stdout.write(args.map(a => String(a)).join(' ') + '\n'); 38 | } catch { 39 | // fallback to noop 40 | } 41 | } 42 | 43 | export function createLogger(debugMode: boolean | string = false): Logger { 44 | const isDebugEnabled = typeof debugMode === 'string' ? debugMode === 'true' : Boolean(debugMode); 45 | 46 | return { 47 | debug: (...args: unknown[]) => debug(isDebugEnabled, ...args), 48 | log: (...args: unknown[]) => debug(isDebugEnabled, ...args), 49 | error, 50 | warn, 51 | info, 52 | }; 53 | } 54 | 55 | export default { debug, error, warn, info, createLogger }; -------------------------------------------------------------------------------- /src/utils/http/proxyLogging.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Proxy logging utilities 3 | * SSOT for request/response logging in proxy middleware 4 | */ 5 | 6 | import { logger } from "../../logging/index.js"; 7 | 8 | interface ProxyRequest { 9 | method: string | undefined; 10 | headers: Record; 11 | body: unknown; 12 | ip: string | undefined; 13 | originalUrl: string | undefined; 14 | path: string; 15 | } 16 | 17 | /** 18 | * Log proxy request details with sanitized body 19 | * Centralizes duplicate logging code from genericProxy and ollamaProxy 20 | */ 21 | export const logRequestDetails = ( 22 | label: string, 23 | req: ProxyRequest, 24 | headers: Record, 25 | body: unknown = null, 26 | ): void => { 27 | logger.debug(`\n[${label}] =====================`); 28 | logger.debug(`[${label}] ${req.method} ${req.originalUrl ?? req.path}`); 29 | if (req.ip && req.ip !== "") { 30 | logger.debug(`[${label}] Client IP: ${req.ip}`); 31 | } 32 | logger.debug(`[${label}] Headers:`, JSON.stringify(headers, null, 2)); 33 | 34 | if (body && req.method !== "GET" && req.method !== "HEAD") { 35 | let safeBody: unknown; 36 | try { 37 | safeBody = JSON.parse(JSON.stringify(body)); 38 | if (typeof safeBody === "object" && safeBody !== null && "api_key" in safeBody) { 39 | (safeBody as Record)["api_key"] = "********"; 40 | } 41 | } catch { 42 | safeBody = "[Unable to parse or clone body]"; 43 | } 44 | logger.debug(`[${label}] Body:`, JSON.stringify(safeBody, null, 2)); 45 | } 46 | logger.debug(`[${label}] =====================\n`); 47 | }; 48 | 49 | export type { ProxyRequest }; 50 | -------------------------------------------------------------------------------- /src/utils/http/handlerUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Handler Utilities - SSOT for shared request handler functionality 3 | * 4 | * Centralizes common logic used across handlers: 5 | * - Auth header extraction 6 | * - Debug logging 7 | * - Success response sending 8 | */ 9 | 10 | import { logger } from '../../logging/index.js'; 11 | import { configService } from '../../services/index.js'; 12 | 13 | import type { Request, Response } from 'express'; 14 | 15 | /** 16 | * Extract authorization header from request 17 | * Returns string if present, undefined otherwise 18 | */ 19 | export function extractAuthHeader(req: Request): string | undefined { 20 | return typeof req.headers.authorization === 'string' 21 | ? req.headers.authorization 22 | : undefined; 23 | } 24 | 25 | /** 26 | * Log response payload if debug mode is enabled 27 | */ 28 | export function logDebugResponse(context: string, response: unknown): void { 29 | if (configService.isDebugMode()) { 30 | logger.debug(`[${context}] Response payload:`, JSON.stringify(response, null, 2)); 31 | } 32 | } 33 | 34 | /** 35 | * Send JSON success response with optional debug logging 36 | */ 37 | export function sendSuccessJSON( 38 | res: Response, 39 | data: unknown, 40 | debugContext?: string 41 | ): void { 42 | if (debugContext) { 43 | logDebugResponse(debugContext, data); 44 | } 45 | res.status(200).json(data); 46 | } 47 | 48 | /** 49 | * Get backend mode and auth header - common pattern across handlers 50 | */ 51 | export function getBackendContext(req: Request): { 52 | backendMode: 'ollama' | 'openai'; 53 | authHeader: string | undefined; 54 | } { 55 | return { 56 | backendMode: configService.getBackendMode(), 57 | authHeader: extractAuthHeader(req), 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type definitions index - exports all types used throughout ToolBridge 3 | */ 4 | 5 | // ToolBridge core types 6 | export type * from './toolbridge.js'; 7 | 8 | // OpenAI types (aliased to avoid conflicts with Ollama) 9 | export type { 10 | OpenAIRequest, 11 | OpenAIResponse, 12 | OpenAIStreamChunk, 13 | OpenAITool, 14 | OpenAIFunction, 15 | OpenAIMessage, 16 | StreamingDelta 17 | } from './openai.js'; 18 | 19 | // Re-export OpenAI Model type from generated sources 20 | export type { Datum as OpenAIModel } from './generated/openai/models-list.js'; 21 | 22 | // Ollama types (aliased to avoid conflicts with OpenAI) 23 | export type { 24 | OllamaRequest, 25 | OllamaResponse, 26 | OllamaStreamChunk, 27 | OllamaMessage, 28 | OllamaModelInfo, 29 | OllamaResponseFields, 30 | OllamaStreamChunkFields 31 | } from './ollama.js'; 32 | 33 | // Express extensions - REMOVED: Not used in codebase 34 | // Node.js stream extensions - REMOVED: Not used in codebase 35 | 36 | // Environment variables 37 | declare global { 38 | namespace NodeJS { 39 | interface ProcessEnv { 40 | PROXY_HOST: string; 41 | PROXY_PORT: string; 42 | BACKEND_LLM_BASE_URL: string; 43 | BACKEND_LLM_API_KEY: string; 44 | OLLAMA_BASE_URL?: string; 45 | OLLAMA_DEFAULT_CONTEXT_LENGTH: string; 46 | DEBUG_MODE: string; 47 | ENABLE_TOOL_REINJECTION: string; 48 | TOOL_REINJECTION_MESSAGE_COUNT: string; 49 | TOOL_REINJECTION_TOKEN_COUNT: string; 50 | TOOL_REINJECTION_TYPE: string; 51 | MAX_STREAM_BUFFER_SIZE: string; 52 | STREAM_CONNECTION_TIMEOUT: string; 53 | // Headers are hardcoded; env overrides are not used 54 | HTTP_REFERER?: string; 55 | X_TITLE?: string; 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /src/translation/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Translation System - Main Export 3 | * 4 | * Universal LLM translation system with generic schema intermediary 5 | * for any-to-any conversions between OpenAI and Ollama. 6 | */ 7 | 8 | // Core translation engine 9 | export { 10 | TranslationEngine, 11 | translationEngine, 12 | translate, 13 | translateResponse, 14 | translateStream, 15 | translateChunk 16 | } from './engine/translator.js'; 17 | 18 | export type { 19 | TranslationOptions, 20 | TranslationResult, 21 | StreamTranslationOptions, 22 | StreamTranslationResult 23 | } from './engine/translator.js'; 24 | 25 | // Provider converters 26 | export { OpenAIConverter } from './converters/openai-simple.js'; 27 | export { OllamaConverter } from './converters/ollama/index.js'; 28 | 29 | export type { ProviderConverter } from './converters/base.js'; 30 | export { 31 | BaseConverter, 32 | ConverterRegistry, 33 | converterRegistry, 34 | getConverter 35 | } from './converters/base.js'; 36 | 37 | // Types (re-export everything) 38 | export * from './types/index.js'; 39 | 40 | // Convenience functions for common use cases 41 | 42 | /** 43 | * Convert OpenAI request to Ollama format 44 | */ 45 | export async function openaiToOllama(request: unknown, strict = false) { 46 | const { translate } = await import('./engine/translator.js'); 47 | return translate({ 48 | from: 'openai', 49 | to: 'ollama', 50 | request, 51 | strict 52 | }); 53 | } 54 | 55 | /** 56 | * Convert Ollama request to OpenAI format 57 | */ 58 | export async function ollamaToOpenai(request: unknown, strict = false) { 59 | const { translate } = await import('./engine/translator.js'); 60 | return translate({ 61 | from: 'ollama', 62 | to: 'openai', 63 | request, 64 | strict 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /src/types/generated/openai/chat-completion-stream-chunk.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | FinishReason, 3 | Logprobs, 4 | ReasoningDetail, 5 | Usage as BaseUsage, 6 | ToolCall as CompletionToolCall, 7 | CompletionTokensDetails as CompletionTokensDetailsBase, 8 | PromptTokensDetails as PromptTokensDetailsBase, 9 | CostDetails as CostDetailsBase 10 | } from "./chat-completion.js"; 11 | 12 | export interface ChatCompletionStreamChunk { 13 | id: string; 14 | provider?: string; 15 | model: string; 16 | object: string; 17 | created: number; 18 | choices: Choice[]; 19 | usage?: Usage; 20 | [key: string]: unknown; 21 | } 22 | 23 | export interface Choice { 24 | index: number; 25 | delta: Delta; 26 | finish_reason?: FinishReason; 27 | native_finish_reason?: string | null; 28 | logprobs?: Logprobs | null; 29 | [key: string]: unknown; 30 | } 31 | 32 | export interface Delta { 33 | role?: string; 34 | content?: string | null; 35 | tool_calls?: StreamToolCall[]; 36 | refusal?: string | null; 37 | reasoning?: string | null; 38 | reasoning_details?: ReasoningDetail[]; 39 | [key: string]: unknown; 40 | } 41 | 42 | export interface StreamToolCall { 43 | index?: number; 44 | id?: string; 45 | type?: "function" | string; 46 | function?: StreamToolFunction; 47 | [key: string]: unknown; 48 | } 49 | 50 | export interface StreamToolFunction { 51 | name?: string; 52 | arguments?: string | Record; 53 | [key: string]: unknown; 54 | } 55 | 56 | export type Usage = BaseUsage; 57 | export type CompletionTokensDetails = CompletionTokensDetailsBase; 58 | export type PromptTokensDetails = PromptTokensDetailsBase; 59 | export type CostDetails = CostDetailsBase; 60 | export type ToolCall = CompletionToolCall; 61 | -------------------------------------------------------------------------------- /src/test/parser/edge-cases/textWithToolCall.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { describe, it } from "mocha"; 3 | 4 | import { extractToolCall } from "../../../parsers/xml/index.js"; 5 | 6 | import type { ExtractedToolCall } from "../../../types/index.js"; 7 | 8 | interface TestCase { 9 | name: string; 10 | input: string; 11 | expectedToolName: string; 12 | } 13 | 14 | describe("Testing XML extraction with surrounding text", function () { 15 | const testCases: TestCase[] = [ 16 | { 17 | name: "Text before XML", 18 | input: `I'll search the codebase for you: 19 | 20 | 21 | How to handle tool calls 22 | `, 23 | expectedToolName: "search", 24 | }, 25 | { 26 | name: "Text after XML", 27 | input: ` 28 | How to handle tool calls 29 | 30 | 31 | Let me explain the results.`, 32 | expectedToolName: "search", 33 | }, 34 | { 35 | name: "Text before and after XML", 36 | input: `Let me help you with that: 37 | 38 | 39 | How to handle tool calls 40 | 41 | 42 | Now I'll analyze the results.`, 43 | expectedToolName: "search", 44 | }, 45 | ]; 46 | 47 | const knownToolNames: string[] = [ 48 | "search", 49 | "run_code", 50 | "analyze", 51 | "replace_string_in_file", 52 | "insert_edit_into_file", 53 | ]; 54 | 55 | testCases.forEach((testCase) => { 56 | it(`should extract tool calls correctly when there is ${testCase.name}`, function () { 57 | const result: ExtractedToolCall | null = extractToolCall(testCase.input, knownToolNames); 58 | expect(result).to.not.be.null; 59 | const r = result as ExtractedToolCall; 60 | expect(r.name).to.equal(testCase.expectedToolName); 61 | }); 62 | }); 63 | }); -------------------------------------------------------------------------------- /src/handlers/ollamaTagsHandler.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ollama /api/tags Handler 3 | * 4 | * CRITICAL: Supports bidirectional translation based on backend mode 5 | * - If backend=Ollama: Passthrough to Ollama backend 6 | * - If backend=OpenAI: Fetch from OpenAI and translate to Ollama format 7 | * 8 | * This ensures ALL endpoints translate based on the configured backend mode. 9 | * 10 | * SSOT: Uses ModelFetcher for bidirectional translation 11 | */ 12 | 13 | import { logger } from '../logging/index.js'; 14 | import { configService } from '../services/configService.js'; 15 | import { modelService } from '../services/index.js'; 16 | import { sendHTTPError } from '../utils/http/errorResponseHandler.js'; 17 | import { extractAuthHeader, sendSuccessJSON } from '../utils/http/handlerUtils.js'; 18 | 19 | import type { TagsResponse } from '../types/generated/ollama/tags.js'; 20 | import type { Request, Response } from 'express'; 21 | 22 | /** 23 | * Handler for /api/tags endpoint 24 | * Translates models based on backend mode 25 | */ 26 | export default async function ollamaTagsHandler(req: Request, res: Response): Promise { 27 | try { 28 | const backendMode = configService.getBackendMode(); 29 | const authHeader = extractAuthHeader(req); 30 | 31 | logger.info(`[OLLAMA TAGS] Request received from ${req.ip} (backend mode: ${backendMode})`); 32 | logger.info(`[OLLAMA TAGS] Authorization header present: ${authHeader ? 'YES' : 'NO'}`); 33 | if (authHeader) { 34 | logger.info(`[OLLAMA TAGS] Auth header format: ${authHeader.substring(0, 20)}...`); 35 | } 36 | 37 | const response = await modelService.listModels('ollama', authHeader) as TagsResponse; 38 | logger.debug(`[OLLAMA TAGS] Returning ${response.models.length} models (backend=${backendMode})`); 39 | 40 | sendSuccessJSON(res, response, 'OLLAMA TAGS'); 41 | } catch (error) { 42 | sendHTTPError(res, error, 'OLLAMA TAGS'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/constants/endpoints.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * API Endpoint Constants - SSOT for all API routes 3 | * 4 | * This file defines all API endpoint paths in ONE place to prevent duplication 5 | * and ensure consistency across the codebase. 6 | * 7 | * SSOT Principle: All endpoint references must import from this file. 8 | */ 9 | 10 | /** 11 | * Ollama API Endpoints 12 | * https://github.com/ollama/ollama/blob/main/docs/api.md 13 | */ 14 | export const OLLAMA_ENDPOINTS = { 15 | /** Chat completions endpoint */ 16 | CHAT: '/api/chat', 17 | 18 | /** Text generation endpoint */ 19 | GENERATE: '/api/generate', 20 | 21 | /** List all available models */ 22 | TAGS: '/api/tags', 23 | 24 | /** Show model information */ 25 | SHOW: '/api/show', 26 | 27 | /** Pull a model from registry */ 28 | PULL: '/api/pull', 29 | 30 | /** Push a model to registry */ 31 | PUSH: '/api/push', 32 | 33 | /** Create a model from Modelfile */ 34 | CREATE: '/api/create', 35 | 36 | /** Delete a model */ 37 | DELETE: '/api/delete', 38 | 39 | /** Copy a model */ 40 | COPY: '/api/copy', 41 | 42 | /** Get Ollama version */ 43 | VERSION: '/api/version', 44 | } as const; 45 | 46 | /** 47 | * OpenAI API Endpoints 48 | * https://platform.openai.com/docs/api-reference 49 | */ 50 | export const OPENAI_ENDPOINTS = { 51 | /** Chat completions endpoint */ 52 | CHAT_COMPLETIONS: '/v1/chat/completions', 53 | 54 | /** List models */ 55 | MODELS: '/v1/models', 56 | 57 | /** Get model info */ 58 | MODEL_INFO: (modelId: string) => `/v1/models/${modelId}`, 59 | 60 | /** Embeddings */ 61 | EMBEDDINGS: '/v1/embeddings', 62 | 63 | /** Legacy completions */ 64 | COMPLETIONS: '/v1/completions', 65 | } as const; 66 | 67 | /** 68 | * ToolBridge Internal Endpoints 69 | */ 70 | export const TOOLBRIDGE_ENDPOINTS = { 71 | /** Health check */ 72 | HEALTH: '/', 73 | 74 | /** Root documentation */ 75 | DOCS: '/', 76 | } as const; 77 | -------------------------------------------------------------------------------- /src/utils/http/proxyUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Proxy Utilities - SSOT for shared proxy middleware functionality 3 | * 4 | * Centralizes common logic used by both genericProxy and ollamaProxy: 5 | * - ProxyResponse type definition 6 | * - Backend headers collection 7 | * - ProxyRequest info construction 8 | */ 9 | 10 | import type { ProxyRequest } from "./proxyLogging.js"; 11 | import type { Request } from "express"; 12 | import type { ClientRequest, IncomingMessage } from "http"; 13 | 14 | /** 15 | * Extended IncomingMessage with statusCode for proxy responses 16 | */ 17 | export interface ProxyResponse extends IncomingMessage { 18 | statusCode?: number; 19 | } 20 | 21 | /** 22 | * Extract original URL from incoming message 23 | */ 24 | export function getOriginalUrl(req: IncomingMessage, fallback: string): string { 25 | const reqWithUrl = req as IncomingMessage & { originalUrl?: string; url?: string }; 26 | return reqWithUrl.originalUrl ?? reqWithUrl.url ?? fallback; 27 | } 28 | 29 | /** 30 | * Collect all headers from proxy request as a record 31 | * Converts number values to strings for consistency 32 | */ 33 | export function collectBackendHeaders( 34 | proxyReq: ClientRequest 35 | ): Record { 36 | const headers: Record = {}; 37 | 38 | for (const name of proxyReq.getHeaderNames()) { 39 | const value = proxyReq.getHeader(name); 40 | headers[name] = typeof value === "number" ? String(value) : value; 41 | } 42 | 43 | return headers; 44 | } 45 | 46 | /** 47 | * Build ProxyRequest info object for logging 48 | */ 49 | export function buildProxyRequestInfo( 50 | expressReq: Request, 51 | backendUrl: string, 52 | body: unknown = undefined 53 | ): ProxyRequest { 54 | return { 55 | method: expressReq.method, 56 | headers: expressReq.headers, 57 | body, 58 | ip: expressReq.ip, 59 | originalUrl: backendUrl, 60 | path: backendUrl, 61 | }; 62 | } 63 | -------------------------------------------------------------------------------- /src/translation/utils/typeGuards.ts: -------------------------------------------------------------------------------- 1 | import type { GenericMessageRole } from '../types/generic.js'; 2 | 3 | const GENERIC_MESSAGE_ROLES: ReadonlySet = new Set([ 4 | 'system', 5 | 'user', 6 | 'assistant', 7 | 'tool', 8 | ]); 9 | /** 10 | * Type Guards - SSOT for Runtime Type Checking 11 | * 12 | * Centralizes type guard utilities used across converters. 13 | * Prevents duplication of type-checking logic. 14 | */ 15 | 16 | /** 17 | * Type alias for unknown record objects 18 | */ 19 | export type UnknownRecord = Record; 20 | 21 | /** 22 | * Type guard to check if value is a plain object (not array, not null) 23 | * 24 | * SSOT for object type checking across all converters. 25 | * Used for parameter normalization and validation. 26 | */ 27 | export const isRecord = (value: unknown): value is UnknownRecord => 28 | typeof value === "object" && value !== null && !Array.isArray(value); 29 | 30 | /** 31 | * Type guard to check if value is a string 32 | */ 33 | export const isString = (value: unknown): value is string => 34 | typeof value === "string"; 35 | 36 | /** 37 | * Type guard to check if value is a number 38 | */ 39 | export const isNumber = (value: unknown): value is number => 40 | typeof value === "number" && !Number.isNaN(value); 41 | 42 | /** 43 | * Type guard to check if value is a boolean 44 | */ 45 | export const isBoolean = (value: unknown): value is boolean => 46 | typeof value === "boolean"; 47 | 48 | /** 49 | * Type guard to check if value is an array 50 | */ 51 | export const isArray = (value: unknown): value is unknown[] => 52 | Array.isArray(value); 53 | 54 | /** 55 | * Type guard to check if value is null or undefined 56 | */ 57 | export const isNullish = (value: unknown): value is null | undefined => 58 | value === null || value === undefined; 59 | 60 | export const isGenericMessageRole = (value: unknown): value is GenericMessageRole => 61 | typeof value === 'string' && GENERIC_MESSAGE_ROLES.has(value as GenericMessageRole); 62 | -------------------------------------------------------------------------------- /src/handlers/stream/wrapperAwareStreamProcessor.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAITool, StreamProcessor } from "../../types/index.js"; 2 | import type { Readable } from "stream"; 3 | 4 | export class WrapperAwareStreamProcessor implements StreamProcessor { 5 | public originalProcessor: StreamProcessor; 6 | 7 | constructor(originalProcessor: StreamProcessor) { 8 | this.originalProcessor = originalProcessor; 9 | } 10 | 11 | setTools(tools?: OpenAITool[]): void { 12 | if (typeof this.originalProcessor.setTools === "function") { 13 | this.originalProcessor.setTools(tools ?? []); 14 | } 15 | } 16 | 17 | processChunk(chunk: Buffer | string): void | Promise { 18 | return this.originalProcessor.processChunk(chunk); 19 | } 20 | 21 | handleDone(): void { 22 | if (typeof this.originalProcessor.handleDone === "function") { 23 | this.originalProcessor.handleDone(); 24 | } 25 | } 26 | 27 | end(): void { 28 | if (typeof this.originalProcessor.end === "function") { 29 | this.originalProcessor.end(); 30 | } 31 | } 32 | 33 | closeStream(message: string | null = null): void { 34 | if (typeof this.originalProcessor.closeStream === "function") { 35 | this.originalProcessor.closeStream(message); 36 | } 37 | } 38 | 39 | closeStreamWithError(errorMessage: string): void { 40 | if (typeof this.originalProcessor.closeStreamWithError === "function") { 41 | this.originalProcessor.closeStreamWithError(errorMessage); 42 | } 43 | } 44 | 45 | pipeFrom(stream: Readable): void { 46 | if (typeof this.originalProcessor.pipeFrom === "function") { 47 | this.originalProcessor.pipeFrom(stream); 48 | return; 49 | } 50 | 51 | stream.on("data", (chunk: Buffer | string) => { 52 | void Promise.resolve(this.processChunk(chunk)); 53 | }); 54 | 55 | stream.on("end", () => { 56 | this.handleDone(); 57 | }); 58 | 59 | stream.on("error", (error: Error) => { 60 | this.closeStreamWithError(error.message); 61 | }); 62 | } 63 | } -------------------------------------------------------------------------------- /src/parsers/xml/utils/xmlValueParsing.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * XML value parsing and type conversion utilities 3 | * Extracted from toolCallParser.ts for KISS compliance 4 | */ 5 | 6 | import { decodeCdataAndEntities } from "./xmlCleaning.js"; 7 | 8 | /** 9 | * Parse a string value to appropriate type (string, number, boolean) 10 | */ 11 | export const parseValue = (value: string): string | number | boolean => { 12 | const trimmed = value.trim(); 13 | // Check if trimmed version is a boolean 14 | if (trimmed.toLowerCase() === 'true') { 15 | return true; 16 | } 17 | if (trimmed.toLowerCase() === 'false') { 18 | return false; 19 | } 20 | // Check if trimmed version is a number 21 | if (!Number.isNaN(Number(trimmed)) && trimmed !== '') { 22 | return Number(trimmed); 23 | } 24 | // Return original value with CDATA/entities decoded (preserve whitespace) 25 | return decodeCdataAndEntities(value); 26 | }; 27 | 28 | /** 29 | * Extract nested object structure from XML 30 | * Handles recursive nesting and arrays 31 | */ 32 | export const extractNestedObject = (xml: string): Record => { 33 | const obj: Record = {}; 34 | const paramRegex = /<([a-zA-Z0-9_.-]+)[^>]*>([\s\S]*?)<\/\1>/g; 35 | let match: RegExpExecArray | null; 36 | 37 | while ((match = paramRegex.exec(xml)) !== null) { 38 | const key = match[1]; 39 | let value: unknown = match[2]; 40 | 41 | if (typeof value === 'string' && value.includes('<') && value.includes('>')) { 42 | value = extractNestedObject(value); 43 | } else if (typeof value === 'string') { 44 | value = parseValue(value); 45 | } 46 | 47 | if (key !== undefined && Object.prototype.hasOwnProperty.call(obj, key)) { 48 | const existing = obj[key]; 49 | if (Array.isArray(existing)) { 50 | existing.push(value); 51 | } else { 52 | obj[key] = [existing, value]; 53 | } 54 | } else if (key !== undefined) { 55 | obj[key] = value; 56 | } 57 | } 58 | 59 | return obj; 60 | }; 61 | -------------------------------------------------------------------------------- /src/parsers/xml/core/WrapperDetector.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wrapper tag detection and unwrapping 3 | * Extracted from toolCallParser.ts for KISS compliance 4 | * 5 | * Handles wrapper tags used to group multiple tool calls 6 | */ 7 | 8 | import { extractBetweenTags } from "../utils/xmlCleaning.js"; 9 | 10 | const WRAPPER_START = ""; 11 | const WRAPPER_END = ""; 12 | 13 | /** 14 | * Get wrapper tag configuration 15 | */ 16 | export const getWrapperTags = () => ({ 17 | start: WRAPPER_START, 18 | end: WRAPPER_END, 19 | example: `${WRAPPER_START}\n \n value\n \n${WRAPPER_END}`, 20 | }); 21 | 22 | /** 23 | * Check if text contains a toolbridge wrapper 24 | */ 25 | export const hasToolCallWrapper = (text: string | null | undefined): boolean => { 26 | if (!text) { 27 | return false; 28 | } 29 | return text.includes(WRAPPER_START); 30 | }; 31 | 32 | /** 33 | * Remove thinking tags from content 34 | * LLMs sometimes wrap tool calls in thinking/reasoning tags 35 | */ 36 | export const removeThinkingTags = (text: string): string => { 37 | return text 38 | .replace(/◁think▷[\s\S]*?◁\/think▷/g, '') 39 | .replace(/[\s\S]*?<\/thinking>/gi, '') 40 | .replace(/[\s\S]*?<\/think>/gi, '') 41 | .replace(/\[thinking\][\s\S]*?\[\/thinking\]/gi, ''); 42 | }; 43 | 44 | /** 45 | * Extract content from within wrapper tags 46 | * Returns null if no wrapper found 47 | * 48 | * IMPORTANT: Strips thinking tags BEFORE extraction to prevent parsing 49 | * tool calls that appear inside blocks (model reasoning). 50 | * The thinking tags are preserved in the actual output stream - this 51 | * stripping only affects tool call parsing. 52 | */ 53 | export const unwrapToolCalls = (text: string): string | null => { 54 | // Strip thinking tags before extraction - tool calls inside are 55 | // model reasoning/planning, NOT actual tool invocations 56 | const withoutThinking = removeThinkingTags(text); 57 | return extractBetweenTags(withoutThinking, WRAPPER_START, WRAPPER_END); 58 | }; 59 | -------------------------------------------------------------------------------- /src/test/utils/run-all-tests-sequential.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { spawnSync } from "child_process"; 4 | import fs from "fs"; 5 | import path from "path"; 6 | import { fileURLToPath } from "url"; 7 | 8 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); 9 | const projectRoot = path.resolve(__dirname, "../../.."); 10 | const testDir = path.join(projectRoot, "src", "test"); 11 | const mochaPath = path.join(projectRoot, "node_modules", ".bin", "mocha"); 12 | 13 | console.log("===== Running All Tests Sequentially ====="); 14 | 15 | const getAllTestFiles = (dir: string): string[] => { 16 | let results: string[] = []; 17 | const list = fs.readdirSync(dir); 18 | 19 | list.forEach((file) => { 20 | const filePath = path.join(dir, file); 21 | const stat = fs.statSync(filePath); 22 | 23 | if (stat.isDirectory()) { 24 | results = results.concat(getAllTestFiles(filePath)); 25 | } else if (file.endsWith(".test.js") || file.endsWith(".test.ts")) { 26 | results.push(filePath); 27 | } 28 | }); 29 | 30 | return results; 31 | }; 32 | 33 | const testFiles = getAllTestFiles(testDir); 34 | 35 | console.log(`Found ${testFiles.length} test files.`); 36 | 37 | let passed = 0; 38 | let failed = 0; 39 | 40 | const errorFiles: string[] = []; 41 | 42 | testFiles.forEach((file) => { 43 | const relativePath = path.relative(projectRoot, file); 44 | console.log(`\nRunning test: ${relativePath}`); 45 | 46 | const result = spawnSync(mochaPath, [file], { 47 | stdio: "inherit", 48 | }); 49 | 50 | if (result.status === 0) { 51 | passed++; 52 | console.log(`✅ Passed: ${relativePath}`); 53 | } else { 54 | failed++; 55 | errorFiles.push(relativePath); 56 | console.log(`❌ Failed: ${relativePath}`); 57 | } 58 | }); 59 | 60 | console.log("\n===== Test Summary ====="); 61 | console.log(`Total test files: ${testFiles.length}`); 62 | console.log(`Passed: ${passed}`); 63 | console.log(`Failed: ${failed}`); 64 | console.log(`Pass rate: ${Math.round((passed / testFiles.length) * 100)}%`); 65 | 66 | if (failed > 0) { 67 | console.log("\nFiles with failures:"); 68 | errorFiles.forEach((file) => { 69 | console.log(`- ${file}`); 70 | }); 71 | } 72 | 73 | process.exit(failed > 0 ? 1 : 0); -------------------------------------------------------------------------------- /src/test/parser/edge-cases/textDuplication.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { describe, it } from "mocha"; 3 | 4 | import { OpenAISSEStreamProcessor } from "../../../handlers/stream/processors/OpenAISSEStreamProcessor.js"; 5 | 6 | import type { Response } from "express"; 7 | 8 | interface Tool { 9 | type: "function"; 10 | function: { 11 | name: string; 12 | description: string; 13 | parameters: { type: 'object'; properties: Record }; 14 | }; 15 | } 16 | 17 | class MockResponse { 18 | private readonly chunks: string[]; 19 | public ended: boolean; 20 | public writableEnded: boolean; 21 | 22 | constructor() { 23 | this.chunks = []; 24 | this.ended = false; 25 | this.writableEnded = false; 26 | } 27 | 28 | write(chunk: string): boolean { 29 | this.chunks.push(chunk); 30 | return true; 31 | } 32 | 33 | setHeader(_name: string, _value: string): void { } 34 | 35 | end(): void { 36 | this.ended = true; 37 | this.writableEnded = true; 38 | } 39 | 40 | getChunks(): string[] { 41 | return this.chunks; 42 | } 43 | } 44 | 45 | describe("Text Duplication Test", function () { 46 | it("should handle text duplication properly", function () { 47 | const mockRes = new MockResponse(); 48 | const processor = new OpenAISSEStreamProcessor(mockRes as unknown as Response); 49 | processor.setTools([ 50 | { 51 | type: "function", 52 | function: { name: "test_tool", description: "Test tool", parameters: { type: 'object', properties: {} } }, 53 | } as Tool, 54 | ]); 55 | 56 | // Send valid SSE chunks with OpenAI structure 57 | const chunk1 = { 58 | id: "123", 59 | choices: [{ delta: { content: "Test content" } }] 60 | }; 61 | const chunk2 = { 62 | id: "124", 63 | choices: [{ delta: { content: "More content" } }] 64 | }; 65 | 66 | processor.processChunk(Buffer.from(`data: ${JSON.stringify(chunk1)}\n\n`)); 67 | processor.processChunk(Buffer.from(`data: ${JSON.stringify(chunk2)}\n\n`)); 68 | 69 | const chunks = mockRes.getChunks(); 70 | expect(chunks.length).to.be.at.least(1); 71 | 72 | const allContent = chunks.join(""); 73 | // The processor emits SSE strings. We check if the content is inside them. 74 | expect(allContent).to.include("Test content"); 75 | expect(allContent).to.include("More content"); 76 | }); 77 | }); -------------------------------------------------------------------------------- /src/utils/http/httpUtils.ts: -------------------------------------------------------------------------------- 1 | import { logger } from '../../logging/index.js'; 2 | 3 | import type { Response } from 'express'; 4 | import type { IncomingHttpHeaders } from 'node:http'; 5 | import type { Readable } from 'stream'; 6 | 7 | interface SSEUpstreamResponse { 8 | statusCode: number; 9 | headers: IncomingHttpHeaders | Record; 10 | body?: Readable | null; 11 | } 12 | 13 | 14 | // Copy headers from upstream response to client response, excluding specified headers 15 | export function copyHeadersExcept( 16 | srcHeaders: IncomingHttpHeaders | Record, 17 | res: Response, 18 | exclude: string[] = [] 19 | ): void { 20 | const normalizedExclude = exclude.map(h => h.toLowerCase()); 21 | 22 | for (const [key, value] of Object.entries(srcHeaders)) { 23 | if (normalizedExclude.includes(key.toLowerCase()) || !value) { 24 | continue; 25 | } 26 | 27 | // Handle both string and string array values 28 | if (Array.isArray(value)) { 29 | res.setHeader(key, value.join(', ')); 30 | } else { 31 | res.setHeader(key, value); 32 | } 33 | } 34 | } 35 | 36 | // Pipe Server-Sent Events from upstream response to client 37 | export async function pipeSSE(up: SSEUpstreamResponse, res: Response): Promise { 38 | res.status(up.statusCode); 39 | res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); 40 | copyHeadersExcept(up.headers, res, ['content-length', 'content-encoding', 'transfer-encoding']); 41 | 42 | if (!up.body) { 43 | logger.error('No body in upstream SSE response'); 44 | res.end(); 45 | return; 46 | } 47 | 48 | try { 49 | // Pipe the readable stream directly to the response 50 | up.body.pipe(res); 51 | 52 | up.body.on('end', () => { 53 | logger.debug('SSE stream ended'); 54 | }); 55 | 56 | up.body.on('error', (error) => { 57 | logger.error('Error in SSE stream:', error); 58 | res.end(); 59 | }); 60 | 61 | } catch (error) { 62 | logger.error('Error piping SSE stream:', error); 63 | res.end(); 64 | } 65 | } 66 | 67 | // Send error response in consistent format 68 | export function sendError(res: Response, statusCode: number, message: string): void { 69 | res.status(statusCode).json({ 70 | error: { 71 | message, 72 | type: 'proxy_error', 73 | code: statusCode.toString() 74 | } 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /src/types/generated/ollama/show.ts: -------------------------------------------------------------------------------- 1 | export interface ShowResponse { 2 | license: string; 3 | modelfile: string; 4 | parameters: string; 5 | template: string; 6 | details: Details; 7 | model_info: ModelInfo; 8 | tensors: Tensor[]; 9 | capabilities: string[]; 10 | modified_at: string; 11 | } 12 | 13 | export interface Details { 14 | parent_model?: string | undefined; 15 | format: string; 16 | family: string; 17 | families: string[]; 18 | parameter_size: string; 19 | quantization_level: string; 20 | } 21 | 22 | export interface ModelInfo { 23 | "general.architecture": string; 24 | "general.basename": string; 25 | "general.file_type": number; 26 | "general.license": string; 27 | "general.parameter_count": number; 28 | "general.quantization_version": number; 29 | "general.size_label": string; 30 | "general.type": string; 31 | "qwen3.attention.head_count": number; 32 | "qwen3.attention.head_count_kv": number; 33 | "qwen3.attention.key_length": number; 34 | "qwen3.attention.layer_norm_rms_epsilon": number; 35 | "qwen3.attention.value_length": number; 36 | "qwen3.block_count": number; 37 | "qwen3.context_length": number; 38 | "qwen3.embedding_length": number; 39 | "qwen3.feed_forward_length": number; 40 | "qwen3.rope.freq_base": number; 41 | "tokenizer.ggml.add_bos_token": boolean; 42 | "tokenizer.ggml.bos_token_id": number; 43 | "tokenizer.ggml.eos_token_id": number; 44 | "tokenizer.ggml.merges": null; 45 | "tokenizer.ggml.model": string; 46 | "tokenizer.ggml.padding_token_id": number; 47 | "tokenizer.ggml.pre": string; 48 | "tokenizer.ggml.token_type": null; 49 | "tokenizer.ggml.tokens": null; 50 | } 51 | 52 | export interface Tensor { 53 | name: string; 54 | type: Type; 55 | shape: number[]; 56 | } 57 | 58 | export enum Type { 59 | F16 = "F16", 60 | F32 = "F32", 61 | Q4K = "Q4_K", 62 | Q6K = "Q6_K", 63 | } 64 | -------------------------------------------------------------------------------- /src/handlers/formatDetector.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Format Detector - DEPRECATED (Constants Only) 3 | * 4 | * @deprecated This module is DEPRECATED. Use formatDetectionService instead. 5 | * 6 | * This file contains ONLY constant exports for backward compatibility. 7 | * DO NOT add executable code here. All detection logic lives in 8 | * src/services/formatDetectionService.ts (SSOT). 9 | * 10 | * Migration Guide: 11 | * ------------------------------------------------------------------ 12 | * OLD (deprecated): 13 | * import { detectRequestFormat } from './formatDetector.js' 14 | * const format = detectRequestFormat(req); 15 | * 16 | * NEW (use SSOT): 17 | * import { formatDetectionService } from '../services/formatDetectionService.js' 18 | * const format = formatDetectionService.detectRequestFormat( 19 | * req.body, 20 | * req.headers, 21 | * req.url 22 | * ); 23 | * ------------------------------------------------------------------ 24 | * 25 | * Why this file exists: 26 | * - Backward compatibility for code that imports FORMAT_* constants 27 | * - Prevents breaking changes during refactoring 28 | * - Will be removed in v2.0.0 29 | * 30 | * SSOT Location: 31 | * - Format detection: src/services/formatDetectionService.ts 32 | * - Format constants: src/translation/types/providers.ts 33 | */ 34 | 35 | import type { RequestFormat } from "../types/index.js"; 36 | 37 | // Re-export format constants for backward compatibility 38 | // SOURCE OF TRUTH: src/translation/types/providers.ts 39 | export const FORMAT_OPENAI: RequestFormat = "openai"; 40 | export const FORMAT_OLLAMA: RequestFormat = "ollama"; 41 | export const FORMAT_UNKNOWN = "unknown"; 42 | 43 | /** 44 | * @deprecated Marker constant indicating this file is deprecated. 45 | * If you're reading this in code, you should migrate to formatDetectionService. 46 | */ 47 | export const DEPRECATED_NOTICE = 48 | 'formatDetector.ts is deprecated. Use formatDetectionService from services layer.'; 49 | 50 | // ═══════════════════════════════════════════════════════════════════ 51 | // NO EXECUTABLE CODE BEYOND THIS POINT 52 | // ═══════════════════════════════════════════════════════════════════ 53 | // 54 | // All detection logic has been moved to: 55 | // - src/services/formatDetectionService.ts (SSOT) 56 | // 57 | // ESLint enforces this via no-restricted-syntax rule. 58 | // If you need detection logic, use formatDetectionService. 59 | // ═══════════════════════════════════════════════════════════════════ -------------------------------------------------------------------------------- /src/handlers/stream/components/NdjsonFormatter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * NdjsonFormatter - Newline-Delimited JSON Formatting Component 3 | * 4 | * SSOT Compliance: Single source of truth for NDJSON formatting (Ollama format). 5 | * Extracted from ollamaStreamProcessor to follow KISS principle. 6 | * 7 | * Purpose: Format chunks as newline-delimited JSON (NDJSON). 8 | * 9 | * Responsibilities: 10 | * - Format data chunks as "{json}\n" 11 | * - Format done signal with done: true flag 12 | * - Format error messages as NDJSON 13 | * 14 | * KISS Compliance: <80 lines, single responsibility, simple interface 15 | */ 16 | 17 | import { logger } from "../../../logging/index.js"; 18 | 19 | /** 20 | * NdjsonFormatter handles newline-delimited JSON formatting (Ollama format) 21 | */ 22 | export class NdjsonFormatter { 23 | /** 24 | * Format a data chunk as NDJSON 25 | * @param data - Data object to format 26 | * @returns NDJSON-formatted string "{json}\n" 27 | */ 28 | formatChunk(data: unknown): string { 29 | return JSON.stringify(data) + "\n"; 30 | } 31 | 32 | /** 33 | * Format the done signal 34 | * @param data - Optional data to include with done flag 35 | * @returns NDJSON-formatted done chunk 36 | */ 37 | formatDone(data?: Record): string { 38 | const doneChunk = { 39 | ...data, 40 | done: true, 41 | }; 42 | 43 | return this.formatChunk(doneChunk); 44 | } 45 | 46 | /** 47 | * Format an error as NDJSON chunk 48 | * @param error - Error message 49 | * @param code - Error code (default: "STREAM_ERROR") 50 | * @returns NDJSON-formatted error chunk 51 | */ 52 | formatError(error: string, code: string = "STREAM_ERROR"): string { 53 | const errorChunk = { 54 | error, 55 | code, 56 | done: true, // Mark as done on error 57 | }; 58 | 59 | logger.debug(`[NDJSON FORMATTER] Formatting error: ${error} (${code})`); 60 | 61 | return this.formatChunk(errorChunk); 62 | } 63 | 64 | /** 65 | * Format a response chunk (Ollama format) 66 | * @param response - Response text 67 | * @param model - Model name 68 | * @param done - Whether this is the final chunk 69 | * @returns NDJSON-formatted response chunk 70 | */ 71 | formatResponse(response: string, model: string, done: boolean = false): string { 72 | const chunk = { 73 | model, 74 | created_at: new Date().toISOString(), 75 | response, 76 | done, 77 | }; 78 | 79 | return this.formatChunk(chunk); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/test/parser/html/tagDetection.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { describe, it } from "mocha"; 3 | 4 | import { detectPotentialToolCall } from "../../../handlers/toolCallHandler.js"; 5 | 6 | import type { ToolCallDetectionResult } from "../../../types/index.js"; 7 | 8 | describe("TagDetection Tests", function () { 9 | const knownToolNames: string[] = [ 10 | "search", 11 | "run_code", 12 | "analyze", 13 | "insert_edit_into_file", 14 | ]; 15 | 16 | it("should not detect HTML content as a tool call", function () { 17 | const htmlContent = `
18 |

Page Title

19 |
20 |
21 |

Some content here.

22 |
`; 23 | 24 | const htmlResult: ToolCallDetectionResult = detectPotentialToolCall(htmlContent, knownToolNames); 25 | expect(htmlResult.isPotential).to.be.false; 26 | }); 27 | 28 | it("should detect valid tool call content", function () { 29 | const toolCallContent = ` 30 | I need to consider several factors here: 31 | 1. Performance implications 32 | 2. Security concerns 33 | `; 34 | 35 | const toolResult: ToolCallDetectionResult = detectPotentialToolCall(toolCallContent, knownToolNames); 36 | expect(toolResult).to.not.be.null; 37 | expect(toolResult.rootTagName).to.equal("analyze"); 38 | expect(toolResult.isPotential).to.be.true; 39 | expect(toolResult.mightBeToolCall).to.be.true; 40 | }); 41 | 42 | it("should not detect HTML-like structure that resembles but is not a known tool", function () { 43 | const similarContent = `
44 | This should not be detected as a tool call 45 |
`; 46 | 47 | const result: ToolCallDetectionResult = detectPotentialToolCall(similarContent, knownToolNames); 48 | expect(result.isPotential).to.be.false; 49 | }); 50 | 51 | it("should detect tool calls with HTML-like content inside them", function () { 52 | const mixedContent = ` 53 | Update the HTML 54 | /path/to/file.html 55 | 56 |
57 |

Updated Title

58 |
59 |
60 |
`; 61 | 62 | const result: ToolCallDetectionResult = detectPotentialToolCall(mixedContent, knownToolNames); 63 | expect(result).to.not.be.null; 64 | expect(result.rootTagName).to.equal("insert_edit_into_file"); 65 | expect(result.isPotential).to.be.true; 66 | expect(result.mightBeToolCall).to.be.true; 67 | }); 68 | }); -------------------------------------------------------------------------------- /src/logging/requestLogger.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | 3 | import logger from "./logger.js"; 4 | 5 | import type { Request } from "express"; 6 | 7 | function formatMethod(method: string): string { 8 | const upperMethod = method.toUpperCase(); 9 | 10 | switch (upperMethod) { 11 | case "GET": 12 | return chalk.green(upperMethod); 13 | case "POST": 14 | return chalk.yellow(upperMethod); 15 | case "PUT": 16 | return chalk.blue(upperMethod); 17 | case "DELETE": 18 | return chalk.red(upperMethod); 19 | case "PATCH": 20 | return chalk.cyan(upperMethod); 21 | default: 22 | return chalk.white(upperMethod); 23 | } 24 | } 25 | 26 | function getStatusColor(status: number): typeof chalk.red { 27 | if (status >= 500) {return chalk.red;} 28 | if (status >= 400) {return chalk.yellow;} 29 | if (status >= 300) {return chalk.cyan;} 30 | if (status >= 200) {return chalk.green;} 31 | return chalk.white; 32 | } 33 | 34 | function getStatusText(status: number): string { 35 | const statusMap: Record = { 36 | 200: "OK", 37 | 201: "Created", 38 | 204: "No Content", 39 | 400: "Bad Request", 40 | 401: "Unauthorized", 41 | 403: "Forbidden", 42 | 404: "Not Found", 43 | 500: "Internal Server Error", 44 | 502: "Bad Gateway", 45 | 503: "Service Unavailable", 46 | }; 47 | 48 | return statusMap[status] ?? ""; 49 | } 50 | 51 | export function logRequest(req: Request, routeName: string): void { 52 | const timestamp = new Date().toISOString(); 53 | const method = formatMethod(req.method); 54 | const endpoint = chalk.cyan(req.originalUrl); 55 | 56 | logger.info(`${chalk.blue("➤")} ${chalk.dim(timestamp)} ${method} ${endpoint} ${chalk.yellow(routeName)}`); 57 | 58 | if ( 59 | routeName.includes("CHAT COMPLETIONS") && 60 | req.body && 61 | (req.body as Record)['stream'] === true 62 | ) { 63 | logger.info(` ${chalk.dim("stream:")} ${chalk.yellow("enabled")}`); 64 | } 65 | } 66 | 67 | export function logResponse( 68 | status: number, 69 | routeName: string, 70 | duration?: number 71 | ): void { 72 | const statusColor = getStatusColor(status); 73 | const statusText = statusColor(`${status} ${getStatusText(status)}`); 74 | 75 | let output = `${chalk.blue("⮑")} ${statusText} ${chalk.yellow(routeName)}`; 76 | 77 | if (duration) { 78 | output += ` ${chalk.dim("in")} ${chalk.magenta(duration + "ms")}`; 79 | } 80 | 81 | logger.info(output); 82 | } 83 | 84 | export default { 85 | logRequest, 86 | logResponse, 87 | }; -------------------------------------------------------------------------------- /src/services/configService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Configuration Service Implementation 3 | * 4 | * SSOT for all configuration. Environment variables are read ONCE at startup. 5 | * All config access MUST go through this service. 6 | */ 7 | 8 | import { 9 | BACKEND_LLM_BASE_URL, 10 | BACKEND_LLM_API_KEY, 11 | BACKEND_MODE, 12 | OPENAI_BACKEND_URL, 13 | OLLAMA_BACKEND_URL, 14 | PROXY_PORT, 15 | PROXY_HOST, 16 | DEBUG_MODE, 17 | ENABLE_TOOL_REINJECTION, 18 | TOOL_REINJECTION_MESSAGE_COUNT, 19 | TOOL_REINJECTION_TOKEN_COUNT, 20 | TOOL_REINJECTION_TYPE, 21 | PASS_TOOLS, 22 | SERVING_MODE, 23 | } from '../config.js'; 24 | 25 | import type { ConfigService } from './contracts.js'; 26 | 27 | class ConfigServiceImpl implements ConfigService { 28 | getBackendUrl(): string { 29 | return BACKEND_LLM_BASE_URL; 30 | } 31 | 32 | getBackendApiKey(): string { 33 | return BACKEND_LLM_API_KEY; 34 | } 35 | 36 | getBackendMode(): 'openai' | 'ollama' { 37 | // Backend mode is always explicitly set, validated at startup 38 | return BACKEND_MODE; 39 | } 40 | 41 | getServingMode(): 'openai' | 'ollama' { 42 | // Serving mode is always explicitly set, validated at startup 43 | return SERVING_MODE; 44 | } 45 | 46 | getOpenAIBackendUrl(): string { 47 | return OPENAI_BACKEND_URL; 48 | } 49 | 50 | getOllamaBackendUrl(): string { 51 | return OLLAMA_BACKEND_URL; 52 | } 53 | 54 | /** 55 | * Get the explicitly configured backend for this deployment. 56 | * Backend mode is NEVER 'auto' - it must be explicitly set by the operator. 57 | * Returns the configured backend; no auto-detection is performed. 58 | */ 59 | detectBackendForModel(): 'openai' | 'ollama' { 60 | // Backend mode is explicitly set, always return it 61 | return this.getBackendMode(); 62 | } 63 | 64 | getProxyPort(): number { 65 | return PROXY_PORT; 66 | } 67 | 68 | getProxyHost(): string { 69 | return PROXY_HOST; 70 | } 71 | 72 | isDebugMode(): boolean { 73 | return DEBUG_MODE; 74 | } 75 | 76 | shouldPassTools(): boolean { 77 | return PASS_TOOLS; 78 | } 79 | 80 | getToolReinjectionConfig(): { 81 | enabled: boolean; 82 | messageCount: number; 83 | tokenCount: number; 84 | type: 'system' | 'user'; 85 | } { 86 | return { 87 | enabled: ENABLE_TOOL_REINJECTION, 88 | messageCount: TOOL_REINJECTION_MESSAGE_COUNT, 89 | tokenCount: TOOL_REINJECTION_TOKEN_COUNT, 90 | type: TOOL_REINJECTION_TYPE, 91 | }; 92 | } 93 | } 94 | 95 | export const configService = new ConfigServiceImpl(); 96 | -------------------------------------------------------------------------------- /src/types/generated/openai/chat-completion.ts: -------------------------------------------------------------------------------- 1 | export type FinishReason = 2 | | "stop" 3 | | "length" 4 | | "tool_calls" 5 | | "function_call" 6 | | "content_filter" 7 | | string 8 | | null; 9 | 10 | export interface ChatCompletionResponse { 11 | id: string; 12 | provider: string; 13 | model: string; 14 | object: string; 15 | created: number; 16 | choices: Choice[]; 17 | system_fingerprint?: string | null; 18 | usage?: Usage; 19 | [key: string]: unknown; 20 | } 21 | 22 | export interface Choice { 23 | index: number; 24 | message: Message; 25 | finish_reason: FinishReason; 26 | native_finish_reason?: string | null; 27 | logprobs?: Logprobs | null; 28 | [key: string]: unknown; 29 | } 30 | 31 | export interface Message { 32 | role: string; 33 | content: string | null; 34 | refusal?: string | null; 35 | reasoning?: string | null; 36 | reasoning_details?: ReasoningDetail[]; 37 | tool_calls?: ToolCall[]; 38 | tool_call_id?: string; 39 | [key: string]: unknown; 40 | } 41 | 42 | export interface ReasoningDetail { 43 | type: string; 44 | text: string; 45 | format: string; 46 | index: number; 47 | [key: string]: unknown; 48 | } 49 | 50 | export interface ToolCall { 51 | function: Function; 52 | id?: string; 53 | index?: number; 54 | type?: string; 55 | [key: string]: unknown; 56 | } 57 | 58 | export interface Function { 59 | arguments: string; 60 | name: string; 61 | [key: string]: unknown; 62 | } 63 | 64 | export interface Usage { 65 | prompt_tokens: number; 66 | completion_tokens: number; 67 | total_tokens: number; 68 | prompt_tokens_details?: PromptTokensDetails | null; 69 | completion_tokens_details?: CompletionTokensDetails | null; 70 | cost?: number; 71 | is_byok?: boolean; 72 | cost_details?: CostDetails | null; 73 | [key: string]: unknown; 74 | } 75 | 76 | export interface PromptTokensDetails { 77 | cached_tokens?: number; 78 | audio_tokens?: number; 79 | video_tokens?: number; 80 | [key: string]: number | undefined; 81 | } 82 | 83 | export interface CompletionTokensDetails { 84 | reasoning_tokens?: number; 85 | image_tokens?: number; 86 | audio_tokens?: number; 87 | [key: string]: number | undefined; 88 | } 89 | 90 | export interface CostDetails { 91 | upstream_inference_cost?: number | null; 92 | upstream_inference_prompt_cost?: number | null; 93 | upstream_inference_completions_cost?: number | null; 94 | [key: string]: number | null | undefined; 95 | } 96 | 97 | export type Logprobs = unknown; 98 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variables file 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build output 91 | .nuxt 92 | dist 93 | 94 | # Remix build output 95 | .cache/ 96 | build/ 97 | public/build/ 98 | 99 | # Docusaurus cache and generated files 100 | .docusaurus 101 | 102 | # Gatsby cache and generated files 103 | .cache/ 104 | public 105 | 106 | # SvelteKit build output 107 | .svelte-kit 108 | 109 | # Strapi cache and generated files 110 | .cache 111 | build 112 | 113 | # Temporary files created by editors 114 | *~ 115 | *.swp 116 | *.swo 117 | 118 | # VS Code settings 119 | .vscode/ 120 | 121 | # macOS specific files 122 | .DS_Store 123 | .AppleDouble 124 | .LSOverride 125 | ._* 126 | .Spotlight-V100 127 | .Trashes 128 | 129 | # package-lock.json (if you want to ignore it) 130 | package-lock.json 131 | reports/ 132 | pnpm-lock.yaml 133 | test-endpoints.sh 134 | test-streaming-raw.sh 135 | -------------------------------------------------------------------------------- /src/test/parser/html/inToolCall.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { describe, it } from "mocha"; 3 | 4 | import { extractToolCall } from "../../../parsers/xml/index.js"; 5 | 6 | import type { ExtractedToolCall } from "../../../types/index.js"; 7 | 8 | describe("HTML in Tool Call Tests", function () { 9 | const knownToolNames: string[] = ["create_file", "insert_edit_into_file", "run_code"]; 10 | 11 | it("should handle HTML content within tool call parameters", function () { 12 | const htmlToolCall = ` 13 | /path/to/file.html 14 | 15 | 16 | 17 | 18 | Sample Page 19 | 20 | 21 |
22 |

Hello World

23 |

This is a paragraph with bold text and italics.

24 |
25 | 26 | 27 |
28 |
`; 29 | 30 | const result: ExtractedToolCall | null = extractToolCall(htmlToolCall, knownToolNames); 31 | 32 | expect(result).to.not.be.null; 33 | expect((result as ExtractedToolCall).name).to.equal("create_file"); 34 | expect((result as ExtractedToolCall).arguments).to.have.property("filePath", "/path/to/file.html"); 35 | expect((result as ExtractedToolCall).arguments).to.have.property("content"); 36 | expect(((result as ExtractedToolCall).arguments as Record)['content']).to.include(""); 37 | expect(((result as ExtractedToolCall).arguments as Record)['content']).to.include('
'); 38 | }); 39 | 40 | it("should handle JavaScript/XML code inside run_code parameters", function () { 41 | const codeToolCall = ` 42 | javascript 43 | 44 | const parseXml = (input) => { 45 | if (input.includes("") && input.includes("")) { 46 | return { 47 | tag: input.match(/(.*?)<\\/tag>/)[1] 48 | }; 49 | } 50 | return null; 51 | }; 52 | 53 | console.log(parseXml("content")); 54 | 55 | `; 56 | 57 | const result: ExtractedToolCall | null = extractToolCall(codeToolCall, knownToolNames); 58 | 59 | expect(result).to.not.be.null; 60 | expect((result as ExtractedToolCall).name).to.equal("run_code"); 61 | expect((result as ExtractedToolCall).arguments).to.have.property("language", "javascript"); 62 | expect((result as ExtractedToolCall).arguments).to.have.property("code"); 63 | expect(((result as ExtractedToolCall).arguments as Record)['code']).to.include("const parseXml"); 64 | expect(((result as ExtractedToolCall).arguments as Record)['code']).to.include(""); 65 | expect(((result as ExtractedToolCall).arguments as Record)['code']).to.include(""); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/test/quick-server-test.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | 3 | /** 4 | * Quick Server Test 5 | * Tests the mock servers individually 6 | */ 7 | 8 | import { spawn } from 'child_process'; 9 | 10 | import axios from 'axios'; 11 | 12 | async function testServer(name: string, port: number, endpoint: string, payload: any) { 13 | console.log(`\nTesting ${name} on port ${port}...`); 14 | 15 | try { 16 | const response = await axios.post(`http://localhost:${port}${endpoint}`, payload, { 17 | timeout: 5000, 18 | validateStatus: () => true // Accept any status 19 | }); 20 | 21 | console.log(`✅ ${name} responded with status ${response.status}`); 22 | console.log(`Response data sample:`, JSON.stringify(response.data).substring(0, 50) + "..."); 23 | return true; 24 | } catch (error: any) { 25 | console.log(`❌ ${name} failed:`, error.message); 26 | return false; 27 | } 28 | } 29 | 30 | async function main() { 31 | console.log('Starting server tests...'); 32 | 33 | // Build first 34 | console.log('\n🔨 Building TypeScript files...'); 35 | await new Promise((resolve, reject) => { 36 | const build = spawn('npm', ['run', 'build'], { stdio: 'inherit' }); 37 | build.on('close', code => code === 0 ? resolve(void 0) : reject(new Error('Build failed'))); 38 | }); 39 | 40 | // Start servers 41 | const servers = []; 42 | 43 | console.log('\n🚀 Starting Mock OpenAI server...'); 44 | const openai = spawn('node', ['dist/test-servers/mock-openai-server.js'], { 45 | env: { ...process.env, PORT: '3001' } 46 | }); 47 | servers.push(openai); 48 | 49 | console.log('🚀 Starting Mock Ollama server...'); 50 | const ollama = spawn('node', ['dist/test-servers/mock-ollama-server.js'], { 51 | env: { ...process.env } 52 | }); 53 | servers.push(ollama); 54 | 55 | // Wait for servers to start 56 | console.log('\n⏳ Waiting for servers to initialize...'); 57 | await new Promise(resolve => setTimeout(resolve, 3100)); 58 | 59 | // Test each server 60 | const results = []; 61 | 62 | results.push(await testServer('Mock OpenAI', 3001, '/v1/chat/completions', { 63 | model: 'gpt-4o', 64 | messages: [{ role: 'user', content: 'Hello!' }] 65 | })); 66 | 67 | results.push(await testServer('Mock Ollama', 11434, '/api/chat', { 68 | model: 'llama3', 69 | messages: [{ role: 'user', content: 'Hello!' }] 70 | })); 71 | 72 | // Summary 73 | console.log('\n📊 Test Summary:'); 74 | console.log('================'); 75 | const passed = results.filter(r => r).length; 76 | const failed = results.filter(r => !r).length; 77 | console.log(`✅ Passed: ${passed}`); 78 | console.log(`❌ Failed: ${failed}`); 79 | 80 | // Cleanup 81 | console.log('\n🧹 Cleaning up servers...'); 82 | servers.forEach(server => server.kill()); 83 | 84 | process.exit(failed > 0 ? 1 : 0); 85 | } 86 | 87 | main().catch(err => { 88 | console.error('Fatal error:', err); 89 | process.exit(1); 90 | }); -------------------------------------------------------------------------------- /src/test/streaming/errorHandling.test.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | 3 | import { expect } from "chai"; 4 | import { describe, it } from "mocha"; 5 | 6 | import { OpenAISSEStreamProcessor } from "../../handlers/stream/processors/OpenAISSEStreamProcessor.js"; 7 | 8 | import type { Response } from "express"; 9 | 10 | describe("Stream Error Handling Tests", function () { 11 | class MockResponse extends EventEmitter { 12 | private readonly chunks: string[]; 13 | 14 | constructor() { 15 | super(); 16 | this.chunks = []; 17 | } 18 | 19 | write(chunk: string): boolean { 20 | this.chunks.push(chunk); 21 | return true; 22 | } 23 | 24 | end(): void { 25 | this.emit("end"); 26 | } 27 | 28 | setHeader(_name: string, _value: string): void { 29 | // no-op 30 | } 31 | 32 | getChunks(): string[] { 33 | return this.chunks; 34 | } 35 | } 36 | 37 | interface TestCase { 38 | name: string; 39 | chunks: string[]; 40 | } 41 | 42 | const testCases: TestCase[] = [ 43 | { 44 | name: "Handle a truncated JSON chunk", 45 | chunks: [ 46 | 'data: {"id":"test1","object":"chat.completion.chunk","choices":[{"delta":{"content":"Hello"}}]}\n\n', 47 | 'data: {"id":"test2","object":"chat.completion.chunk","choices":[{"delta":{"content":" world"}}]}\n\n', 48 | 'data: {"id":"test3","object":"chat.completion.chunk","created":12345,"model":"test-model","choices":[{"index":0,"delta":{"content":null},"finish_reason":"stop"}],"usage":{"prompt', 49 | '_tokens":123}}\n\n', 50 | "data: [DONE]\n\n", 51 | ], 52 | }, 53 | { 54 | name: "Handle malformed JSON chunk", 55 | chunks: [ 56 | 'data: {"id":"test1","object":"chat.completion.chunk","choices":[{"delta":{"content":"Processing"}}]}\n\n', 57 | 'data: {"id":"test2",object:"chat.completion.chunk","choices":[{"delta":{"content":" data"}}]}\n\n', 58 | 'data: {"id":"test3","object":"chat.completion.chunk","choices":[{"delta":{"content":"..."}}]}\n\n', 59 | "data: [DONE]\n\n", 60 | ], 61 | }, 62 | ]; 63 | 64 | testCases.forEach((testCase) => { 65 | it(`should ${testCase.name}`, function (done) { 66 | const mockRes = new MockResponse(); 67 | const processor = new OpenAISSEStreamProcessor(mockRes as unknown as Response); testCase.chunks.forEach((chunk) => { 68 | try { 69 | processor.processChunk(chunk); 70 | } catch (e: unknown) { 71 | const error = e instanceof Error ? e : new Error(String(e)); 72 | expect.fail(`Processor threw an unhandled exception: ${error.message}`); 73 | } 74 | }); 75 | 76 | const responseChunks = mockRes.getChunks(); 77 | expect(responseChunks.length).to.be.at.least(1); 78 | 79 | const allContent = responseChunks.join(""); 80 | expect(allContent).to.not.be.empty; 81 | 82 | done(); 83 | }); 84 | }); 85 | }); -------------------------------------------------------------------------------- /src/test/parser/llm-patterns/fuzzyContent.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { describe, it } from "mocha"; 3 | 4 | import { detectPotentialToolCall } from "../../../handlers/toolCallHandler.js"; 5 | import { extractToolCall } from "../../../parsers/xml/index.js"; 6 | 7 | import type { ToolCallDetectionResult, ExtractedToolCall } from "../../../types/index.js"; 8 | 9 | describe("Fuzzy LLM Content Tests", function () { 10 | const knownToolNames: string[] = ["search", "analyze", "run_code"]; 11 | 12 | it("should extract tool calls from mixed markdown and code content", function () { 13 | const complexInput = `This user prefers dark theme and has notifications enabled.`; 14 | 15 | const result: ToolCallDetectionResult = detectPotentialToolCall(complexInput, knownToolNames); 16 | expect(result).to.not.be.null; 17 | expect(result.rootTagName).to.equal("analyze"); 18 | expect(result.isPotential).to.be.true; 19 | 20 | const extracted: ExtractedToolCall | null = extractToolCall(complexInput, knownToolNames); 21 | expect(extracted).to.not.be.null; 22 | expect(extracted).to.not.be.null; 23 | const ex = extracted as ExtractedToolCall; 24 | expect(ex.name).to.equal("analyze"); 25 | expect(ex.arguments).to.be.a("object"); 26 | }); 27 | 28 | it("should handle minimalist tool calls", function () { 29 | const minimalistToolCall = `Simple analysis.`; 30 | 31 | const result: ExtractedToolCall | null = extractToolCall(minimalistToolCall, knownToolNames); 32 | expect(result).to.not.be.null; 33 | expect((result as ExtractedToolCall).name).to.equal("analyze"); 34 | expect((result as ExtractedToolCall).arguments).to.be.a("object"); 35 | }); 36 | 37 | it("should document behavior with tool calls followed by text", function () { 38 | const toolCallWithTrailingText = `Analysis.\nFollowed by more text`; 39 | 40 | const detected: ToolCallDetectionResult = detectPotentialToolCall( 41 | toolCallWithTrailingText, 42 | knownToolNames, 43 | ); 44 | expect(detected).to.not.be.null; 45 | expect(detected.rootTagName).to.equal("analyze"); 46 | expect(detected.isPotential).to.be.true; 47 | 48 | const result: ExtractedToolCall | null = extractToolCall( 49 | toolCallWithTrailingText, 50 | knownToolNames, 51 | ); 52 | expect(result).to.not.be.null; 53 | expect((result as ExtractedToolCall).name).to.equal("analyze"); 54 | expect((result as ExtractedToolCall).arguments).to.be.a("object"); 55 | }); 56 | 57 | it("should extract tool calls with text before but not after", function () { 58 | const toolCallWithLeadingText = `Here's my analysis: Simple analysis.`; 59 | 60 | const result: ExtractedToolCall | null = extractToolCall( 61 | toolCallWithLeadingText, 62 | knownToolNames, 63 | ); 64 | expect(result).to.not.be.null; 65 | expect((result as ExtractedToolCall).name).to.equal("analyze"); 66 | expect((result as ExtractedToolCall).arguments).to.be.a("object"); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/test/unit/services/modelServiceSyntheticShow.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | 3 | import { modelFormatter } from "../../../services/model/ModelFormatter.js"; 4 | 5 | import type { OllamaModelInfo, UniversalModel } from "../../../translation/types/models.js"; 6 | 7 | describe("ModelService synthetic Ollama show payload", () => { 8 | // Test the formatter's createOllamaModelInfo method 9 | const service = { 10 | createOllamaModelInfo(model: UniversalModel): OllamaModelInfo { 11 | return modelFormatter.createOllamaModelInfo(model); 12 | }, 13 | }; 14 | 15 | it("should mirror core Ollama fields when upstream metadata includes license", () => { 16 | const universalModel: UniversalModel = { 17 | id: "gpt-4o-mini", 18 | name: "gpt-4o-mini", 19 | description: "OpenAI test model", 20 | contextLength: 128000, 21 | size: 3_200_000_000, 22 | quantization: "Q4_K_M", 23 | family: "qwen3", 24 | capabilities: { 25 | chat: true, 26 | completion: true, 27 | embedding: false, 28 | vision: true, 29 | tools: true, 30 | functionCalling: true, 31 | }, 32 | pricing: { 33 | promptTokens: 5, 34 | completionTokens: 15, 35 | }, 36 | metadata: { 37 | created: 1_700_000_000, 38 | owned_by: "openai", 39 | license: "apache-2.0", 40 | modified_at: "2024-01-01T00:00:00.000Z", 41 | }, 42 | }; 43 | 44 | const response = service.createOllamaModelInfo(universalModel); 45 | 46 | expect(response.license).to.include("Apache License"); 47 | expect(response.modelfile).to.include('TEMPLATE """'); 48 | expect(response.parameters).to.include("temperature"); 49 | expect(response.template).to.include("<|im_start|>"); 50 | expect(response.details.families).to.include("qwen3"); 51 | expect(response.capabilities).to.include("completion"); 52 | expect(response.model_info).to.have.property("general.architecture", "qwen3"); 53 | expect(response.model_info).to.have.property("toolbridge.backend_mode"); 54 | expect(response.tensors).to.be.an("array").that.is.empty; 55 | expect(response.modified_at).to.equal("2024-01-01T00:00:00.000Z"); 56 | }); 57 | 58 | it("should fall back to synthetic license and metadata when license is missing", () => { 59 | const universalModel: UniversalModel = { 60 | id: "mystery-model", 61 | name: "mystery-model", 62 | capabilities: { 63 | chat: true, 64 | completion: true, 65 | embedding: false, 66 | vision: false, 67 | tools: false, 68 | functionCalling: false, 69 | }, 70 | metadata: {}, 71 | }; 72 | 73 | const response = service.createOllamaModelInfo(universalModel); 74 | 75 | expect(response.license).to.include("Apache License"); 76 | expect(response.model_info).to.have.property("general.architecture"); 77 | expect(response.model_info).to.have.property("general.license", "unknown"); 78 | expect(response.capabilities).to.include("chat"); 79 | expect(response.details.families).to.include(response.details.family); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/utils/http/headerUtils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BACKEND_LLM_API_KEY, 3 | HTTP_REFERER, 4 | PLACEHOLDER_API_KEY, 5 | X_TITLE, 6 | } from "../../config.js"; 7 | import { FORMAT_OLLAMA, FORMAT_OPENAI } from "../../handlers/formatDetector.js"; 8 | import { logger } from "../../logging/index.js"; 9 | 10 | import type { RequestFormat } from "../../types/index.js"; 11 | 12 | interface BackendHeaders { 13 | "Content-Type": string; 14 | "Authorization"?: string; 15 | "HTTP-Referer": string; // mandatory 16 | "Referer": string; // mandatory 17 | "X-Title": string; // mandatory 18 | [key: string]: string | undefined; 19 | } 20 | 21 | interface ClientHeaders { 22 | [key: string]: string | string[] | undefined; 23 | } 24 | 25 | /** 26 | * Headers to preserve from client request, organized by provider. 27 | */ 28 | const PASSTHROUGH_HEADERS = { 29 | openai: [ 30 | 'openai-organization', 31 | 'openai-project', 32 | 'user-agent', 33 | 'x-custom-header', 34 | ], 35 | ollama: [ 36 | 'user-agent', 37 | 'x-custom-header', 38 | ], 39 | }; 40 | 41 | export function buildBackendHeaders( 42 | clientAuthHeader?: string, 43 | clientHeaders?: ClientHeaders, 44 | _context: string = "unknown", 45 | clientFormat: RequestFormat = FORMAT_OPENAI, 46 | targetProvider: string = 'openai', 47 | ): BackendHeaders { 48 | const headers: BackendHeaders = { 49 | "Content-Type": "application/json", 50 | "HTTP-Referer": HTTP_REFERER, 51 | "Referer": HTTP_REFERER, 52 | "X-Title": X_TITLE, 53 | }; 54 | 55 | const useOllamaAuth = clientFormat === FORMAT_OLLAMA; 56 | 57 | // Authentication handling 58 | // OpenAI and Ollama use Bearer token 59 | if (BACKEND_LLM_API_KEY && BACKEND_LLM_API_KEY !== PLACEHOLDER_API_KEY) { 60 | headers["Authorization"] = `Bearer ${BACKEND_LLM_API_KEY}`; 61 | logger.debug( 62 | `[AUTH] Using configured ${useOllamaAuth ? "OLLAMA_API_KEY" : "BACKEND_LLM_API_KEY"} for ${clientFormat} format client`, 63 | ); 64 | } else if (useOllamaAuth) { 65 | logger.debug( 66 | `[AUTH] No API key configured. Assuming Ollama backend doesn't require auth.`, 67 | ); 68 | } else if (clientAuthHeader) { 69 | headers["Authorization"] = clientAuthHeader; 70 | logger.debug( 71 | `[AUTH] Using client-provided Authorization header for OpenAI format client (no server key configured).`, 72 | ); 73 | } else { 74 | logger.warn( 75 | `[AUTH] Warning: No client Authorization header and no BACKEND_LLM_API_KEY configured. Request will likely fail.`, 76 | ); 77 | } 78 | 79 | // Passthrough provider-specific headers 80 | const passthroughList = PASSTHROUGH_HEADERS[targetProvider as keyof typeof PASSTHROUGH_HEADERS] || []; 81 | if (clientHeaders) { 82 | for (const headerName of passthroughList) { 83 | const clientValue = clientHeaders[headerName]; 84 | if (clientValue !== undefined) { 85 | const headerValue = Array.isArray(clientValue) ? clientValue.join(',') : clientValue; 86 | headers[headerName] = headerValue; 87 | logger.debug(`[HEADERS] Passed through ${headerName} from client`); 88 | } 89 | } 90 | } 91 | 92 | return headers; 93 | } -------------------------------------------------------------------------------- /src/test/unit/utils/retryHelpers.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | 3 | import { fetchWithRetry, isRateLimitError, retryWithBackoff } from "../../utils/retryHelpers.js"; 4 | 5 | describe("retryHelpers", () => { 6 | describe("retryWithBackoff", () => { 7 | it("retries until the operation succeeds", async () => { 8 | let attempts = 0; 9 | const result = await retryWithBackoff( 10 | async () => { 11 | attempts += 1; 12 | if (attempts < 3) { 13 | throw new Error("temporary"); 14 | } 15 | return "ok"; 16 | }, 17 | { 18 | maxRetries: 5, 19 | baseDelayMs: 0, 20 | maxDelayMs: 0, 21 | shouldRetry: () => true, 22 | } 23 | ); 24 | 25 | expect(result).to.equal("ok"); 26 | expect(attempts).to.equal(3); 27 | }); 28 | 29 | it("short-circuits when shouldRetry returns false", async () => { 30 | let attempts = 0; 31 | try { 32 | await retryWithBackoff( 33 | async () => { 34 | attempts += 1; 35 | throw new Error("fatal"); 36 | }, 37 | { 38 | maxRetries: 5, 39 | baseDelayMs: 0, 40 | maxDelayMs: 0, 41 | shouldRetry: () => false, 42 | } 43 | ); 44 | expect.fail("retryWithBackoff should have thrown"); 45 | } catch (error) { 46 | expect((error as Error).message).to.equal("fatal"); 47 | } 48 | 49 | expect(attempts).to.equal(1); 50 | }); 51 | }); 52 | 53 | describe("fetchWithRetry", () => { 54 | const originalFetch = globalThis.fetch; 55 | 56 | afterEach(() => { 57 | globalThis.fetch = originalFetch; 58 | }); 59 | 60 | it("retries 429 responses before succeeding", async () => { 61 | const fetchCalls: number[] = []; 62 | const responses = [ 63 | new Response(null, { status: 429, headers: { "retry-after": "0" } }), 64 | new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" } }), 65 | ]; 66 | 67 | globalThis.fetch = async () => { 68 | fetchCalls.push(responses.length); 69 | return responses.shift() ?? new Response(null, { status: 500 }); 70 | }; 71 | 72 | const response = await fetchWithRetry("http://example.test", {}, { 73 | maxRetries: 3, 74 | baseDelayMs: 0, 75 | maxDelayMs: 0, 76 | }); 77 | 78 | expect(fetchCalls).to.have.length(2); 79 | expect(response.status).to.equal(200); 80 | const payload = await response.json() as { ok?: boolean }; 81 | expect(payload.ok).to.be.true; 82 | }); 83 | }); 84 | 85 | describe("isRateLimitError", () => { 86 | it("detects 429 status and messages", () => { 87 | expect(isRateLimitError({ status: 429 })).to.be.true; 88 | expect(isRateLimitError(new Error("HTTP 429"))).to.be.true; 89 | expect(isRateLimitError("rate limit exceeded")).to.be.true; 90 | expect(isRateLimitError({ message: "Rate limit" })).to.be.true; 91 | expect(isRateLimitError(new Error("other"))).to.be.false; 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0.0", 3 | "name": "ToolBridge", 4 | "description": "Universal LLM Tool Proxy Server with advanced stream processing and format conversion", 5 | "_comments": { 6 | "configuration": "This file contains the configuration (modes, ports, timeouts). Sensitive values (API keys, credentials) go in .env file.", 7 | "servingMode": "What API format ToolBridge serves to clients: MUST be 'openai' or 'ollama' (explicitly set, never 'auto')", 8 | "backendMode": "What type of LLM provider your backend is: MUST be 'openai' or 'ollama' (explicitly set, never 'auto')", 9 | "passTools": "When true, original tools/tool_choice fields are kept in backend payload. When false, they are removed. Tool instructions are ALWAYS added to system messages regardless." 10 | }, 11 | "server": { 12 | "servingMode": "openai", 13 | "defaultHost": "0.0.0.0", 14 | "defaultPort": 3100, 15 | "defaultDebugMode": true 16 | }, 17 | "backends": { 18 | "defaultMode": "openai", 19 | "supportedModes": [ 20 | "openai", 21 | "ollama" 22 | ], 23 | "defaultChatPath": "/chat/completions", 24 | "defaultBaseUrls": { 25 | "openai": "https://openrouter.ai/api", 26 | "ollama": "http://localhost:11434" 27 | }, 28 | "ollama": { 29 | "defaultContextLength": 32768, 30 | "defaultUrl": "http://localhost:11434" 31 | } 32 | }, 33 | "tools": { 34 | "enableReinjection": true, 35 | "reinjectionMessageCount": 3, 36 | "reinjectionTokenCount": 1000, 37 | "reinjectionType": "system", 38 | "maxIterations": 5, 39 | "passTools": false 40 | }, 41 | "performance": { 42 | "maxBufferSize": 1048576, 43 | "connectionTimeout": 120000, 44 | "maxStreamBufferSize": 1048576, 45 | "streamConnectionTimeout": 120000 46 | }, 47 | "testing": { 48 | "server": { 49 | "proxyPort": 3100, 50 | "proxyHost": "localhost", 51 | "portRangeStart": 3100, 52 | "portRangeEnd": 3200 53 | }, 54 | "mockServers": { 55 | "openaiPort": 3001, 56 | "ollamaPort": 3002, 57 | "defaultResponseDelay": 100 58 | }, 59 | "models": { 60 | "openaiCompatible": "openrouter/polaris-alpha", 61 | "ollama": "gemma3:1b", 62 | "fallbacks": { 63 | "openaiCompatible": "qwen/qwen-2-7b-instruct:free", 64 | "ollama": "qwen3:latest" 65 | } 66 | }, 67 | "backends": { 68 | "openaiCompatibleUrl": "https://api.openai.com/v1", 69 | "ollamaUrl": "http://localhost:11434" 70 | }, 71 | "timeouts": { 72 | "standard": 30000, 73 | "connection": 120000, 74 | "socket": 1000, 75 | "portWait": 30000 76 | }, 77 | "features": { 78 | "enableStreamTesting": true, 79 | "enableToolCalling": true, 80 | "enableDualClient": true, 81 | "concurrentTestLimit": 5 82 | } 83 | }, 84 | "headers": { 85 | "httpReferer": "https://github.com/Oct4Pie/toolbridge", 86 | "xTitle": "toolbridge" 87 | }, 88 | "validation": { 89 | "requiredEnvVars": { 90 | "openai": [ 91 | "BACKEND_LLM_API_KEY" 92 | ], 93 | "ollama": [] 94 | }, 95 | "placeholders": { 96 | "apiKey": "YOUR_API_KEY_HERE" 97 | } 98 | } 99 | } -------------------------------------------------------------------------------- /src/test/parser/xml/mutationTesting.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { describe, it } from "mocha"; 3 | 4 | import { extractToolCall } from "../../../parsers/xml/index.js"; 5 | 6 | describe("Mutation Testing for XML Parser", function () { 7 | const knownToolNames: string[] = [ 8 | "search", 9 | "run_code", 10 | "analyze", 11 | "replace_string_in_file", 12 | "insert_edit_into_file", 13 | "get_errors", 14 | ]; 15 | 16 | const validXMLSamples: string[] = [ 17 | ` 18 | How to implement binary search? 19 | `, 20 | 21 | ` 22 | 23 | I need to analyze the performance implications of using a recursive approach versus an iterative approach for traversing a binary tree. 24 | 25 | `, 26 | 27 | ` 28 | javascript 29 | 30 | function fibonacci(n) { 31 | if (n <= 1) return n; 32 | return fibonacci(n-1) + fibonacci(n-2); 33 | } 34 | console.log(fibonacci(10)); 35 | 36 | `, 37 | ]; 38 | 39 | function createMutations(xml: string): string[] { 40 | return [ 41 | xml.replace(/<\/(\w+)>(?!.*<\/)/g, "(?!.*<\/)/g, ""), 44 | 45 | xml.replace(/>([^<]+)$1<"), 46 | 47 | xml.replace(/<(\w+)>/g, "<$1 invalidAttr=>"), 48 | 49 | `Some random text before ${xml}`, 50 | 51 | xml.replace(/>(.*?) 52 | p1.includes(" ") 53 | ? `>${p1 54 | .split(" ") 55 | .map((word, i) => (i % 2 ? `${word}` : word)) 56 | .join(" ")}<` 57 | : match, 58 | ), 59 | 60 | xml.replace(/(<\/\w+>)\s*(<\/\w+>)/g, "$2\n $1"), 61 | 62 | xml.replace(/>([^<]+) \n$1\n <"), 63 | 64 | xml.replace(/<(\w+)>/g, "<$1><$1>"), 65 | 66 | xml.replace(/<(\w+)>(?!.*<\1>)/g, ""), 67 | 68 | xml.replace(//g, ">"), 69 | 70 | xml.replace(/<(\w+)>/g, (_match: string, p1: string) => `<${p1.toUpperCase()}>`), 71 | 72 | xml.replace(/>([^<]+)$1${String.fromCodePoint(0x1f600)}<`), 73 | ]; 74 | } 75 | 76 | it("should handle various mutations of valid XML", function () { 77 | let totalTests = 0; 78 | let passedTests = 0; 79 | 80 | validXMLSamples.forEach((validXML) => { 81 | const mutations = createMutations(validXML); 82 | 83 | mutations.forEach((mutation, i) => { 84 | totalTests++; 85 | try { 86 | extractToolCall(mutation, knownToolNames); 87 | passedTests++; 88 | } catch (error: unknown) { 89 | if (error && (error as Error).message) { 90 | passedTests++; 91 | } else { 92 | console.error(`Failed on mutation ${i}:`, mutation); 93 | } 94 | } 95 | }); 96 | }); 97 | 98 | console.log( 99 | `Passed ${passedTests} of ${totalTests} mutation tests (${Math.round((passedTests / totalTests) * 100)}%)`, 100 | ); 101 | 102 | expect(passedTests / totalTests).to.be.at.least(0.75); 103 | }); 104 | }); -------------------------------------------------------------------------------- /src/types/generated/openai/models-list.ts: -------------------------------------------------------------------------------- 1 | export interface ModelsListResponse { 2 | data: Datum[]; 3 | } 4 | 5 | export interface Datum { 6 | id: string; 7 | canonical_slug: string; 8 | hugging_face_id: null | string; 9 | name: string; 10 | created: number; 11 | description: string; 12 | context_length: number; 13 | architecture: Architecture; 14 | pricing: Pricing; 15 | top_provider: TopProvider; 16 | per_request_limits: null; 17 | supported_parameters: SupportedParameter[]; 18 | default_parameters: DefaultParameters | null; 19 | } 20 | 21 | export interface Architecture { 22 | modality: Modality; 23 | input_modalities: PutModality[]; 24 | output_modalities: PutModality[]; 25 | tokenizer: Tokenizer; 26 | instruct_type: null | string; 27 | } 28 | 29 | export enum PutModality { 30 | Audio = "audio", 31 | File = "file", 32 | Image = "image", 33 | Text = "text", 34 | Video = "video", 35 | } 36 | 37 | export enum Modality { 38 | TextImageText = "text+image->text", 39 | TextImageTextImage = "text+image->text+image", 40 | TextText = "text->text", 41 | } 42 | 43 | export enum Tokenizer { 44 | Claude = "Claude", 45 | Cohere = "Cohere", 46 | DeepSeek = "DeepSeek", 47 | GPT = "GPT", 48 | Gemini = "Gemini", 49 | Grok = "Grok", 50 | Llama2 = "Llama2", 51 | Llama3 = "Llama3", 52 | Llama4 = "Llama4", 53 | Mistral = "Mistral", 54 | Nova = "Nova", 55 | Other = "Other", 56 | Qwen = "Qwen", 57 | Qwen3 = "Qwen3", 58 | Router = "Router", 59 | } 60 | 61 | export interface DefaultParameters { 62 | temperature?: number | null; 63 | top_p?: number | null; 64 | frequency_penalty?: null; 65 | } 66 | 67 | export interface Pricing { 68 | prompt: string; 69 | completion: string; 70 | request?: string; 71 | image?: string; 72 | web_search?: string; 73 | internal_reasoning?: string; 74 | input_cache_read?: string; 75 | audio?: string; 76 | input_cache_write?: string; 77 | } 78 | 79 | export enum SupportedParameter { 80 | FrequencyPenalty = "frequency_penalty", 81 | IncludeReasoning = "include_reasoning", 82 | LogitBias = "logit_bias", 83 | Logprobs = "logprobs", 84 | MaxTokens = "max_tokens", 85 | MinP = "min_p", 86 | PresencePenalty = "presence_penalty", 87 | Reasoning = "reasoning", 88 | RepetitionPenalty = "repetition_penalty", 89 | ResponseFormat = "response_format", 90 | Seed = "seed", 91 | Stop = "stop", 92 | StructuredOutputs = "structured_outputs", 93 | Temperature = "temperature", 94 | ToolChoice = "tool_choice", 95 | Tools = "tools", 96 | TopA = "top_a", 97 | TopK = "top_k", 98 | TopLogprobs = "top_logprobs", 99 | TopP = "top_p", 100 | WebSearchOptions = "web_search_options", 101 | } 102 | 103 | export interface TopProvider { 104 | context_length: number | null; 105 | max_completion_tokens: number | null; 106 | is_moderated: boolean; 107 | } 108 | -------------------------------------------------------------------------------- /src/services/translationService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Translation Service Implementation 3 | * 4 | * SSOT for all format conversions. All handlers MUST use this service. 5 | * Direct converter imports in handlers are FORBIDDEN. 6 | */ 7 | 8 | import { setupStreamHandler } from '../handlers/streamingHandler.js'; 9 | import { translate, translateResponse } from '../translation/index.js'; 10 | 11 | import { configService } from './configService.js'; 12 | 13 | import type { TranslationService } from './contracts.js'; 14 | import type { LLMProvider } from '../translation/types/index.js'; 15 | import type { OpenAITool, RequestFormat } from '../types/index.js'; 16 | import type { Response } from 'express'; 17 | import type { Readable } from 'stream'; 18 | 19 | class TranslationServiceImpl implements TranslationService { 20 | async translateRequest( 21 | request: unknown, 22 | from: LLMProvider, 23 | to: LLMProvider, 24 | toolNames: string[] 25 | ): Promise { 26 | const result = await translate({ 27 | from, 28 | to, 29 | request, 30 | context: { 31 | knownToolNames: toolNames, 32 | enableXMLToolParsing: toolNames.length > 0, 33 | passTools: configService.shouldPassTools(), 34 | toolReinjection: configService.getToolReinjectionConfig(), 35 | }, 36 | }); 37 | 38 | if (!result.success) { 39 | throw result.error ?? new Error('Translation failed'); 40 | } 41 | 42 | return result.data; 43 | } 44 | 45 | async translateResponse( 46 | response: unknown, 47 | from: LLMProvider, 48 | to: LLMProvider, 49 | toolNames: string[] 50 | ): Promise { 51 | const result = await translateResponse( 52 | response, 53 | from, 54 | to, 55 | { 56 | knownToolNames: toolNames, 57 | enableXMLToolParsing: toolNames.length > 0, 58 | } 59 | ); 60 | 61 | if (!result.success) { 62 | throw result.error ?? new Error('Response translation failed'); 63 | } 64 | 65 | return result.data; 66 | } 67 | 68 | translateStream( 69 | _stream: Readable, 70 | _from: LLMProvider, 71 | _to: LLMProvider, 72 | _tools: OpenAITool[], 73 | _streamOptions?: { include_usage?: boolean } 74 | ): Readable { 75 | // This is a simplified adapter - in practice we'd return a PassThrough stream 76 | // For now, we keep the existing setupStreamHandler pattern but will refactor 77 | throw new Error('Stream translation requires Response object - use setupStreamHandler directly for now'); 78 | } 79 | 80 | /** 81 | * Temporary bridge method until we refactor streaming to not require Response 82 | */ 83 | setupStreamTranslation( 84 | backendStream: Readable, 85 | res: Response, 86 | clientFormat: RequestFormat, 87 | backendFormat: RequestFormat, 88 | tools: OpenAITool[], 89 | options?: { 90 | streamOptions?: { include_usage?: boolean }; 91 | clientRequestBody?: unknown; 92 | } 93 | ): void { 94 | setupStreamHandler( 95 | backendStream, 96 | res, 97 | clientFormat, 98 | backendFormat, 99 | tools, 100 | options?.streamOptions, 101 | options?.clientRequestBody 102 | ); 103 | } 104 | } 105 | 106 | export const translationService = new TranslationServiceImpl(); 107 | -------------------------------------------------------------------------------- /src/handlers/stream/components/SseFormatter.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * SseFormatter - Server-Sent Events Formatting Component 3 | * 4 | * SSOT Compliance: Single source of truth for SSE formatting. 5 | * Extracted from openaiStreamProcessor to follow KISS principle. 6 | * 7 | * Purpose: Format chunks as Server-Sent Events (SSE) protocol. 8 | * 9 | * Responsibilities: 10 | * - Format data chunks as SSE "data: {json}\n\n" 11 | * - Format done signal as "data: [DONE]\n\n" 12 | * - Format error messages as SSE chunks 13 | * 14 | * KISS Compliance: <100 lines, single responsibility, simple interface 15 | */ 16 | 17 | import { logger } from "../../../logging/index.js"; 18 | import { formatSSEChunk } from "../../../utils/http/index.js"; 19 | 20 | /** 21 | * SseFormatter handles Server-Sent Events formatting 22 | */ 23 | export class SseFormatter { 24 | /** 25 | * Format a data chunk as SSE 26 | * @param data - Data object to format 27 | * @returns SSE-formatted string "data: {json}\n\n" 28 | */ 29 | formatChunk(data: unknown): string { 30 | return formatSSEChunk(data); 31 | } 32 | 33 | /** 34 | * Format the done signal 35 | * @returns SSE-formatted done signal "data: [DONE]\n\n" 36 | */ 37 | formatDone(): string { 38 | return "data: [DONE]\n\n"; 39 | } 40 | 41 | /** 42 | * Format an error as SSE chunk 43 | * @param error - Error message 44 | * @param code - Error code (default: "STREAM_ERROR") 45 | * @returns SSE-formatted error chunk 46 | */ 47 | formatError(error: string, code: string = "STREAM_ERROR"): string { 48 | const errorChunk = { 49 | error: { 50 | message: error, 51 | code, 52 | }, 53 | }; 54 | 55 | logger.debug(`[SSE FORMATTER] Formatting error: ${error} (${code})`); 56 | 57 | return formatSSEChunk(errorChunk); 58 | } 59 | 60 | /** 61 | * Format a content delta chunk (OpenAI format) 62 | * @param content - Content delta 63 | * @param model - Model name 64 | * @param index - Choice index (default: 0) 65 | * @returns SSE-formatted content chunk 66 | */ 67 | formatContentDelta(content: string, model: string, index: number = 0): string { 68 | const chunk = { 69 | id: `chatcmpl-${Date.now()}`, 70 | object: "chat.completion.chunk", 71 | created: Math.floor(Date.now() / 1000), 72 | model, 73 | choices: [ 74 | { 75 | index, 76 | delta: { content }, 77 | finish_reason: null, 78 | }, 79 | ], 80 | }; 81 | 82 | return formatSSEChunk(chunk); 83 | } 84 | 85 | /** 86 | * Format a finish chunk (OpenAI format) 87 | * @param model - Model name 88 | * @param finishReason - Finish reason (default: "stop") 89 | * @param index - Choice index (default: 0) 90 | * @returns SSE-formatted finish chunk 91 | */ 92 | formatFinish( 93 | model: string, 94 | finishReason: string = "stop", 95 | index: number = 0 96 | ): string { 97 | const chunk = { 98 | id: `chatcmpl-${Date.now()}`, 99 | object: "chat.completion.chunk", 100 | created: Math.floor(Date.now() / 1000), 101 | model, 102 | choices: [ 103 | { 104 | index, 105 | delta: {}, 106 | finish_reason: finishReason, 107 | }, 108 | ], 109 | }; 110 | 111 | return formatSSEChunk(chunk); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/translation/utils/resultHelpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Translation Result Helpers 3 | * 4 | * Utility functions for creating consistent translation results. 5 | */ 6 | 7 | import { TranslationError } from '../types/generic.js'; 8 | 9 | import type { 10 | ConversionContext, 11 | CompatibilityResult, 12 | } from '../types/index.js'; 13 | import type { 14 | TranslationResult, 15 | StreamTranslationResult, 16 | } from '../types/translator.js'; 17 | 18 | /** 19 | * Create a successful passthrough result (same provider, no conversion needed) 20 | */ 21 | export function createPassthroughResult( 22 | data: unknown, 23 | context: ConversionContext 24 | ): TranslationResult { 25 | return { 26 | success: true, 27 | data, 28 | compatibility: { compatible: true, warnings: [], unsupportedFeatures: [], transformations: [] }, 29 | context, 30 | transformations: [] 31 | }; 32 | } 33 | 34 | /** 35 | * Create a successful translation result 36 | */ 37 | export function createSuccessResult( 38 | data: unknown, 39 | compatibility: CompatibilityResult, 40 | context: ConversionContext 41 | ): TranslationResult { 42 | return { 43 | success: true, 44 | data, 45 | compatibility, 46 | context, 47 | transformations: context.transformationLog ?? [] 48 | }; 49 | } 50 | 51 | /** 52 | * Create an error translation result 53 | */ 54 | export function createErrorResult( 55 | error: unknown, 56 | context: ConversionContext 57 | ): TranslationResult { 58 | const translationError = error instanceof Error 59 | ? new TranslationError(error.message, 'CONVERSION_FAILED', context, error) 60 | : new TranslationError('Unknown conversion error', 'CONVERSION_FAILED', context); 61 | 62 | return { 63 | success: false, 64 | error: translationError, 65 | compatibility: { compatible: false, warnings: [], unsupportedFeatures: [], transformations: [] }, 66 | context, 67 | transformations: context.transformationLog ?? [] 68 | }; 69 | } 70 | 71 | /** 72 | * Create a successful stream passthrough result 73 | */ 74 | export function createStreamPassthroughResult( 75 | stream: ReadableStream, 76 | context: ConversionContext 77 | ): StreamTranslationResult { 78 | return { 79 | success: true, 80 | stream, 81 | compatibility: { compatible: true, warnings: [], unsupportedFeatures: [], transformations: [] }, 82 | context 83 | }; 84 | } 85 | 86 | /** 87 | * Create a successful stream translation result 88 | */ 89 | export function createStreamSuccessResult( 90 | stream: ReadableStream, 91 | compatibility: CompatibilityResult, 92 | context: ConversionContext 93 | ): StreamTranslationResult { 94 | return { 95 | success: true, 96 | stream, 97 | compatibility, 98 | context 99 | }; 100 | } 101 | 102 | /** 103 | * Create an error stream translation result 104 | */ 105 | export function createStreamErrorResult( 106 | error: unknown, 107 | context: ConversionContext 108 | ): StreamTranslationResult { 109 | const translationError = error instanceof Error 110 | ? new TranslationError(error.message, 'CONVERSION_FAILED', context, error) 111 | : new TranslationError('Stream conversion error', 'CONVERSION_FAILED', context); 112 | 113 | return { 114 | success: false, 115 | error: translationError, 116 | compatibility: { compatible: false, warnings: [], unsupportedFeatures: [], transformations: [] }, 117 | context 118 | }; 119 | } 120 | -------------------------------------------------------------------------------- /src/test/streaming/htmlTool.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { describe, it } from "mocha"; 3 | 4 | import { detectPotentialToolCall } from "../../handlers/toolCallHandler.js"; 5 | import { extractToolCall } from "../../parsers/xml/index.js"; 6 | 7 | import type { ToolCallDetectionResult, ExtractedToolCall } from "../../types/index.js"; 8 | 9 | describe("HtmlTool Tests", function () { 10 | class MockProcessor { 11 | public toolCallBuffer: string; 12 | private readonly knownToolNames: string[]; 13 | public isPotentialToolCall: boolean; 14 | public results: ExtractedToolCall[]; 15 | 16 | constructor() { 17 | this.toolCallBuffer = ""; 18 | this.knownToolNames = [ 19 | "insert_edit_into_file", 20 | "create_file", 21 | "test_tool", 22 | ]; 23 | this.isPotentialToolCall = false; 24 | this.results = []; 25 | } 26 | 27 | processChunk(chunk: Buffer | string): void { 28 | const chunkStr = chunk.toString(); 29 | 30 | this.toolCallBuffer += chunkStr; 31 | 32 | try { 33 | const detected: ToolCallDetectionResult = detectPotentialToolCall( 34 | this.toolCallBuffer, 35 | this.knownToolNames, 36 | ); 37 | 38 | this.isPotentialToolCall = 39 | detected.isPotential && detected.mightBeToolCall; 40 | 41 | if (this.isPotentialToolCall) { 42 | try { 43 | const extracted: ExtractedToolCall | null = extractToolCall(this.toolCallBuffer); 44 | if (extracted) { 45 | this.results.push(extracted); 46 | this.toolCallBuffer = ""; 47 | this.isPotentialToolCall = false; 48 | } 49 | } catch (_error: unknown) { 50 | const error = _error instanceof Error ? _error : new Error(String(_error)); 51 | console.log("Expected extraction error in test:", error.message); 52 | } 53 | } 54 | } catch (_error: unknown) { 55 | const error = _error instanceof Error ? _error : new Error(String(_error)); 56 | console.log("Error processing chunk in test:", error.message); 57 | } 58 | } 59 | } 60 | 61 | it("should process HTML chunks correctly", function () { 62 | const processor = new MockProcessor(); 63 | 64 | const chunks: string[] = [ 65 | `Here's a simple HTML document:`, 66 | `\n`, 67 | `\n /test/index.html`, 68 | `\n `, 69 | `\n`, 70 | `\n`, 71 | `\n Test Page`, 72 | `\n`, 73 | `\n`, 74 | `\n

Hello World

`, 75 | `\n

This is a test page with emphasis and strong text.

`, 76 | `\n`, 77 | `\n`, 78 | `\n
`, 79 | `\n
`, 80 | ]; 81 | 82 | chunks.forEach((chunk) => processor.processChunk(chunk)); 83 | 84 | expect(processor.toolCallBuffer).to.be.a("string"); 85 | 86 | if (processor.results.length > 0) { 87 | const firstResult = processor.results[0]; 88 | if (firstResult) { 89 | expect(firstResult.name).to.equal("create_file"); 90 | } 91 | } else { 92 | expect(processor.toolCallBuffer).to.include(""); 93 | expect(processor.toolCallBuffer).to.include(""); 94 | expect(processor.toolCallBuffer).to.include(""); 95 | expect(processor.toolCallBuffer).to.include(""); 96 | } 97 | }); 98 | }); -------------------------------------------------------------------------------- /src/translation/types/models.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Model Translation Types 3 | * 4 | * SSOT Strategy: Use generated types from src/types/generated/ 5 | * This file provides type aliases and the universal intermediate format. 6 | */ 7 | 8 | import type { Model as GeneratedOllamaModel } from '../../types/generated/ollama/tags.js'; 9 | import type { Datum } from '../../types/generated/openai/models-list.js'; 10 | 11 | // ============================================================ 12 | // GENERATED TYPES (SSOT) - Re-export with aliases 13 | // ============================================================ 14 | 15 | /** 16 | * OpenAI model format (from /v1/models endpoint) 17 | * Uses generated Datum type which represents a single OpenAI model 18 | */ 19 | export type { Datum as OpenAIModel } from '../../types/generated/openai/models-list.js'; 20 | 21 | /** 22 | * OpenAI models list response 23 | * Extended from generated ModelsListResponse to include 'object' field for OpenAI API compatibility 24 | */ 25 | export interface OpenAIModelsResponse { 26 | object: string; // OpenAI API always includes 'object': 'list' 27 | data: Datum[]; // Array of OpenAI models 28 | } 29 | 30 | /** 31 | * Ollama model format (from /api/tags endpoint) 32 | * Uses generated Model type, extended with ToolBridge capabilities 33 | */ 34 | export interface OllamaModel extends GeneratedOllamaModel { 35 | capabilities?: string[]; // ToolBridge enhancement: indicate model capabilities 36 | } 37 | 38 | export type { TagsResponse as OllamaModelsResponse } from '../../types/generated/ollama/tags.js'; 39 | 40 | /** 41 | * Ollama model info (from /api/show endpoint) 42 | * Uses generated ShowResponse type which represents detailed model information 43 | */ 44 | export type { ShowResponse as OllamaModelInfo } from '../../types/generated/ollama/show.js'; 45 | 46 | // ============================================================ 47 | // INTERNAL UNIVERSAL FORMAT (not generated - ToolBridge specific) 48 | // ============================================================ 49 | 50 | /** 51 | * Universal model representation (intermediate format) 52 | * This is ToolBridge's internal format for model translation 53 | */ 54 | export interface UniversalModel { 55 | id: string; 56 | name: string; 57 | description?: string; 58 | contextLength?: number; 59 | size?: number; 60 | quantization?: string; 61 | family?: string; 62 | capabilities: { 63 | chat: boolean; 64 | completion: boolean; 65 | embedding: boolean; 66 | vision: boolean; 67 | tools: boolean; 68 | functionCalling: boolean; 69 | }; 70 | pricing?: { 71 | promptTokens?: number; 72 | completionTokens?: number; 73 | }; 74 | metadata: Record; 75 | } 76 | 77 | // ============================================================ 78 | // MODEL CONVERTER INTERFACE 79 | // ============================================================ 80 | 81 | /** 82 | * Model converter interface 83 | * Defines bidirectional conversion between OpenAI, Ollama, and Universal formats 84 | * Uses the type aliases defined above (OpenAIModel, OllamaModel) 85 | */ 86 | export interface ModelConverter { 87 | /** 88 | * Convert OpenAI model to universal format 89 | */ 90 | fromOpenAI(model: Datum): UniversalModel; 91 | 92 | /** 93 | * Convert Ollama model to universal format 94 | */ 95 | fromOllama(model: OllamaModel): UniversalModel; 96 | 97 | /** 98 | * Convert universal model to OpenAI format 99 | */ 100 | toOpenAI(model: UniversalModel): Datum; 101 | 102 | /** 103 | * Convert universal model to Ollama format 104 | */ 105 | toOllama(model: UniversalModel): OllamaModel; 106 | } 107 | -------------------------------------------------------------------------------- /src/parsers/xml/utils/xmlParsing.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Low-level XML parsing utilities 3 | * Extracted from toolCallParser.ts for KISS compliance 4 | */ 5 | 6 | /** 7 | * Check if a character is a valid XML name character 8 | */ 9 | export const isNameChar = (ch: string): boolean => /[A-Za-z0-9_.:-]/.test(ch); 10 | 11 | /** 12 | * Extract local name from a qualified name (strip namespace prefix) 13 | */ 14 | export const getLocalName = (qName: string): string => { 15 | const idx = qName.lastIndexOf(":"); 16 | return idx >= 0 ? qName.slice(idx + 1) : qName; 17 | }; 18 | 19 | /** 20 | * Read text until a terminator string is found 21 | */ 22 | export const readUntil = (text: string, start: number, terminator: string): number => { 23 | const end = text.indexOf(terminator, start); 24 | return end >= 0 ? end + terminator.length : text.length; 25 | }; 26 | 27 | /** 28 | * Skip over a tag's body (handles quotes properly) 29 | */ 30 | export const skipTagBody = (text: string, start: number): number => { 31 | let inQuote: '"' | "'" | null = null; 32 | for (let i = start; i < text.length; i++) { 33 | const char = text[i]; 34 | if (inQuote) { 35 | if (char === inQuote) { 36 | inQuote = null; 37 | } 38 | continue; 39 | } 40 | if (char === '"' || char === "'") { 41 | inQuote = char; 42 | continue; 43 | } 44 | if (char === '>') { 45 | return i + 1; 46 | } 47 | } 48 | return text.length; 49 | }; 50 | 51 | /** 52 | * Parse tag name from text at given position 53 | * Extracted to eliminate duplication between parseStartTag and parseEndTag (DRY) 54 | */ 55 | const parseTagName = (text: string, startPointer: number): { name: string; endPointer: number } | null => { 56 | let pointer = startPointer; 57 | let name = ''; 58 | while (pointer < text.length) { 59 | const char = text[pointer]; 60 | if (!char || !isNameChar(char)) { 61 | break; 62 | } 63 | name += char; 64 | pointer++; 65 | } 66 | if (!name) { 67 | return null; 68 | } 69 | return { name, endPointer: pointer }; 70 | }; 71 | 72 | /** 73 | * Parse a start tag from text at a given index 74 | */ 75 | export type StartTag = { 76 | name: string; 77 | local: string; 78 | start: number; 79 | end: number; 80 | selfClosing: boolean; 81 | }; 82 | 83 | export const parseStartTag = (text: string, index: number): StartTag | null => { 84 | let pointer = index + 1; 85 | if (pointer >= text.length) { 86 | return null; 87 | } 88 | 89 | const next = text[pointer]; 90 | if (next === '/' || next === '!' || next === '?') { 91 | return null; 92 | } 93 | 94 | const parsed = parseTagName(text, pointer); 95 | if (!parsed) { 96 | return null; 97 | } 98 | const { name, endPointer } = parsed; 99 | pointer = endPointer; 100 | 101 | let afterName = pointer; 102 | afterName = skipTagBody(text, afterName); 103 | const raw = text.slice(index, afterName); 104 | const selfClosing = /\/>\s*$/.test(raw); 105 | return { 106 | name, 107 | local: getLocalName(name), 108 | start: index, 109 | end: afterName, 110 | selfClosing, 111 | }; 112 | }; 113 | 114 | /** 115 | * Parse an end tag from text at a given index 116 | */ 117 | export const parseEndTag = (text: string, index: number): { name: string; local: string; end: number } | null => { 118 | const parsed = parseTagName(text, index + 2); 119 | if (!parsed) { 120 | return null; 121 | } 122 | let { name, endPointer: pointer } = parsed; 123 | while (pointer < text.length && text[pointer] !== '>') { 124 | pointer++; 125 | } 126 | return { 127 | name, 128 | local: getLocalName(name), 129 | end: pointer < text.length ? pointer + 1 : pointer, 130 | }; 131 | }; 132 | -------------------------------------------------------------------------------- /src/test/unit/translation/passToolsTransform.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { describe, it } from "mocha"; 3 | import { applyTransformations } from "../../../translation/utils/transformationUtils.js"; 4 | 5 | import type { 6 | CompatibilityResult, 7 | ConversionContext, 8 | GenericLLMRequest, 9 | } from "../../../translation/types/index.js"; 10 | 11 | const baseCompatibility: CompatibilityResult = { 12 | compatible: true, 13 | warnings: [], 14 | unsupportedFeatures: [], 15 | transformations: [], 16 | }; 17 | 18 | const createContext = (overrides: Partial = {}): ConversionContext => ({ 19 | sourceProvider: "openai", 20 | targetProvider: "openai", 21 | requestId: "test", 22 | preserveExtensions: true, 23 | strictMode: false, 24 | knownToolNames: ["test_tool"], 25 | enableXMLToolParsing: true, 26 | transformationLog: [], 27 | ...overrides, 28 | }); 29 | 30 | describe("applyTransformations with passTools=false", () => { 31 | it("strips native tool fields and injects XML instructions", () => { 32 | const request: GenericLLMRequest = { 33 | provider: "openai", 34 | model: "test-model", 35 | messages: [ 36 | { 37 | role: "user", 38 | content: "Please call a tool.", 39 | }, 40 | ], 41 | tools: [ 42 | { 43 | type: "function", 44 | function: { 45 | name: "test_tool", 46 | description: "Test tool", 47 | parameters: { 48 | type: "object", 49 | properties: { 50 | query: { type: "string" }, 51 | }, 52 | required: ["query"], 53 | }, 54 | }, 55 | }, 56 | ], 57 | toolChoice: "auto", 58 | }; 59 | 60 | const context = createContext({ passTools: false }); 61 | 62 | const logSteps: Array<{ step: string; description: string }> = []; 63 | const result = applyTransformations( 64 | request, 65 | baseCompatibility, 66 | context, 67 | (ctx, step, description) => { 68 | ctx.transformationLog?.push({ step, description, timestamp: Date.now() }); 69 | logSteps.push({ step, description }); 70 | } 71 | ); 72 | 73 | expect("tools" in result).to.be.false; 74 | expect("toolChoice" in result).to.be.false; 75 | 76 | const systemMessage = result.messages.find((msg) => msg.role === "system"); 77 | expect(systemMessage).to.not.be.undefined; 78 | expect(typeof systemMessage?.content).to.equal("string"); 79 | expect(String(systemMessage?.content)).to.include(""); 80 | expect(String(systemMessage?.content)).to.include("IMPORTANT: The tools listed above"); 81 | 82 | const stripStep = logSteps.find((entry) => entry.step === "strip_native_tools"); 83 | expect(stripStep).to.not.be.undefined; 84 | }); 85 | 86 | it("adds directive forbidding tool usage when toolChoice is none", () => { 87 | const request: GenericLLMRequest = { 88 | provider: "openai", 89 | model: "test-model", 90 | messages: [ 91 | { 92 | role: "user", 93 | content: "No tools please.", 94 | }, 95 | ], 96 | tools: [], 97 | toolChoice: "none", 98 | }; 99 | 100 | const context = createContext({ passTools: false }); 101 | 102 | const result = applyTransformations( 103 | request, 104 | baseCompatibility, 105 | context, 106 | () => { } 107 | ); 108 | 109 | const systemMessage = result.messages.find((msg) => msg.role === "system"); 110 | expect(systemMessage).to.not.be.undefined; 111 | expect(String(systemMessage?.content)).to.include("Tool usage is disabled for this request"); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /src/test/runners/run-integration-sequential.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /** 3 | * Sequential Integration Test Runner 4 | * 5 | * Runs integration tests ONE AT A TIME to avoid port conflicts. 6 | * Each test gets its own port via the port manager. 7 | */ 8 | 9 | import { execSync } from 'child_process'; 10 | import * as path from 'path'; 11 | import { fileURLToPath } from 'url'; 12 | 13 | const __filename = fileURLToPath(import.meta.url); 14 | const __dirname = path.dirname(__filename); 15 | 16 | const integrationTests = [ 17 | 'comprehensive-tool-calling.test.js', 18 | 'brutality.test.js', 19 | 'bidirectional-conversion.test.js', 20 | 'comprehensive-real-clients.test.js', 21 | 'real-clients-xml-toolcalls.test.js', 22 | 'end-to-end-real-client.test.js', 23 | ]; 24 | 25 | const testDir = path.join(__dirname, '../integration'); 26 | 27 | console.log(''); 28 | console.log('╔══════════════════════════════════════════════════════════════╗'); 29 | console.log('║ 🧪 Running Integration Tests Sequentially ║'); 30 | console.log('║ Each test gets a unique port to avoid conflicts ║'); 31 | console.log('╚══════════════════════════════════════════════════════════════╝'); 32 | console.log(''); 33 | 34 | let totalPassed = 0; 35 | let totalFailed = 0; 36 | let totalPending = 0; 37 | 38 | for (const testFile of integrationTests) { 39 | const testPath = path.join(testDir, testFile); 40 | 41 | console.log(`\n${'='.repeat(70)}`); 42 | console.log(`Running: ${testFile}`); 43 | console.log('='.repeat(70)); 44 | 45 | try { 46 | const output = execSync(`npx mocha "${testPath}" --reporter spec`, { 47 | encoding: 'utf-8', 48 | stdio: 'pipe', 49 | env: { 50 | ...process.env, 51 | // Each test will request its own port from port manager 52 | } 53 | }); 54 | 55 | console.log(output); 56 | 57 | // Parse results 58 | const passingMatch = output.match(/(\d+) passing/); 59 | const failingMatch = output.match(/(\d+) failing/); 60 | const pendingMatch = output.match(/(\d+) pending/); 61 | 62 | if (passingMatch?.[1]) totalPassed += parseInt(passingMatch[1], 10); 63 | if (failingMatch?.[1]) totalFailed += parseInt(failingMatch[1], 10); 64 | if (pendingMatch?.[1]) totalPending += parseInt(pendingMatch[1], 10); 65 | 66 | // Small delay between tests 67 | await new Promise(resolve => setTimeout(resolve, 2000)); 68 | 69 | } catch (error) { 70 | console.error(`\n❌ Test failed: ${testFile}`); 71 | if (error instanceof Error && 'stdout' in error) { 72 | console.log((error as {stdout: Buffer}).stdout.toString()); 73 | } 74 | if (error instanceof Error && 'stderr' in error) { 75 | console.error((error as {stderr: Buffer}).stderr.toString()); 76 | } 77 | 78 | // Try to extract failing count 79 | const errorOutput = error instanceof Error && 'stdout' in error 80 | ? (error as {stdout: Buffer}).stdout.toString() 81 | : ''; 82 | const failingMatch = errorOutput.match(/(\d+) failing/); 83 | if (failingMatch?.[1]) { 84 | totalFailed += parseInt(failingMatch[1], 10); 85 | } else { 86 | totalFailed += 1; // Count the whole test as failed 87 | } 88 | } 89 | } 90 | 91 | console.log('\n'); 92 | console.log('╔══════════════════════════════════════════════════════════════╗'); 93 | console.log('║ 📊 Final Results ║'); 94 | console.log('╚══════════════════════════════════════════════════════════════╝'); 95 | console.log(''); 96 | console.log(`✅ Passing: ${totalPassed}`); 97 | console.log(`❌ Failing: ${totalFailed}`); 98 | console.log(`⏭️ Pending: ${totalPending}`); 99 | console.log(''); 100 | 101 | if (totalFailed > 0) { 102 | process.exit(1); 103 | } 104 | -------------------------------------------------------------------------------- /src/parsers/xml/utils/xmlCleaning.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * XML text cleaning and preprocessing utilities 3 | * Extracted from toolCallParser.ts for KISS compliance 4 | */ 5 | 6 | /** 7 | * Decode common HTML entities in text 8 | */ 9 | export const decodeHtmlEntities = (text: string): string => { 10 | const entities: Record = { 11 | "&": "&", 12 | "<": "<", 13 | ">": ">", 14 | """: '"', 15 | "'": "'", 16 | "'": "'", 17 | " ": " ", 18 | }; 19 | return text.replace(/&(?:amp|lt|gt|quot|apos|#39|nbsp);/g, (match) => entities[match] ?? match); 20 | }; 21 | 22 | /** 23 | * Extract CDATA content and decode HTML entities 24 | * Replaces CDATA sections with their inner content, then decodes entities 25 | */ 26 | export const decodeCdataAndEntities = (text: string): string => { 27 | // Replace all CDATA sections with their inner content 28 | let out = text.replace(//g, (_, inner) => inner); 29 | out = decodeHtmlEntities(out); 30 | return out; 31 | }; 32 | 33 | /** 34 | * Preprocess text for XML parsing 35 | * - Strips XML declarations 36 | * - Extracts XML from code blocks 37 | * - Handles XML in comments 38 | * - Handles JSON-wrapped XML 39 | * - Trims leading non-XML content 40 | */ 41 | export const preprocessForParsing = (text: string): string | null => { 42 | let processed = text; 43 | 44 | if (processed.includes(']*\?>\s*/i, ''); 46 | } 47 | 48 | const codeBlockRegex = /```(?:xml|markup|)[\s\n]?([\s\S]*?)[\s\n]?```/i; 49 | const codeBlockMatch = codeBlockRegex.exec(processed); 50 | if (codeBlockMatch?.[1]) { 51 | processed = codeBlockMatch[1]; 52 | } 53 | 54 | const xmlCommentRegex = //; 55 | const xmlCommentMatch = xmlCommentRegex.exec(processed); 56 | const commentContent = xmlCommentMatch?.[1]?.trim(); 57 | if (commentContent && commentContent.startsWith('<') && commentContent.endsWith('>')) { 58 | processed = commentContent; 59 | } 60 | 61 | if (processed.includes('{"') && processed.includes('"<') && processed.includes('>"}')) { 62 | const jsonXmlMatch = processed.match(/["']([^"']*<[^"']*>[^"']*)["']/); 63 | if (jsonXmlMatch?.[1]) { 64 | processed = jsonXmlMatch[1]; 65 | } 66 | } 67 | 68 | const firstTagIndex = processed.indexOf('<'); 69 | if (firstTagIndex > 0) { 70 | processed = processed.substring(firstTagIndex); 71 | } else if (firstTagIndex === -1) { 72 | return null; 73 | } 74 | 75 | processed = processed.trim(); 76 | if (!processed.startsWith('<') || !processed.endsWith('>')) { 77 | return processed; 78 | } 79 | 80 | return processed; 81 | }; 82 | 83 | /** 84 | * Extract content between opening and closing wrapper tags 85 | * Handles multiple occurrences, returns innermost valid match 86 | */ 87 | export const extractBetweenTags = (text: string, startTag: string, endTag: string): string | null => { 88 | const startIndices: number[] = []; 89 | let cursor = 0; 90 | while (cursor < text.length) { 91 | const index = text.indexOf(startTag, cursor); 92 | if (index === -1) { 93 | break; 94 | } 95 | startIndices.push(index); 96 | cursor = index + 1; 97 | } 98 | 99 | for (let i = startIndices.length - 1; i >= 0; i--) { 100 | const startIndex = startIndices[i]; 101 | if (startIndex === undefined) { 102 | continue; 103 | } 104 | 105 | const contentStart = startIndex + startTag.length; 106 | const endIndex = text.indexOf(endTag, contentStart); 107 | if (endIndex !== -1) { 108 | const content = text.substring(contentStart, endIndex).trim(); 109 | if (content.startsWith('<') && content.includes('>')) { 110 | return content; 111 | } 112 | } 113 | } 114 | return null; 115 | }; 116 | -------------------------------------------------------------------------------- /src/types/openai.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * OpenAI API Types 3 | * 4 | * SSOT Strategy: ALL types come from src/types/generated/openai/ 5 | * This file ONLY re-exports and adds request types (which cannot be auto-generated). 6 | */ 7 | 8 | // ============================================================ 9 | // GENERATED RESPONSE TYPES (SSOT) - Re-export ONLY 10 | // ============================================================ 11 | export type { 12 | ChatCompletionResponse, 13 | ChatCompletionStreamChunk, 14 | ModelsListResponse, 15 | Model as OpenAIModel, 16 | Choice, 17 | Message, 18 | ToolCall, 19 | ToolFunction, 20 | ReasoningDetail, 21 | Usage, 22 | CompletionTokensDetails, 23 | Delta 24 | } from './generated/openai/index.js'; 25 | 26 | // Type aliases for backward compatibility 27 | export type { 28 | ChatCompletionResponse as OpenAIResponse, 29 | ChatCompletionStreamChunk as OpenAIStreamChunk, 30 | ModelsListResponse as OpenAIModelsListResponse, 31 | Choice as OpenAIChoice, 32 | Message as OpenAIResponseMessage, 33 | Usage as OpenAIUsage, 34 | ToolCall as OpenAIToolCall, 35 | Delta as OpenAIStreamDelta 36 | } from './generated/openai/index.js'; 37 | 38 | // ============================================================ 39 | // UTILITY TYPES FOR CONVERTERS/HANDLERS 40 | // ============================================================ 41 | 42 | // Streaming delta can have partial fields during streaming (first chunk has role, content chunks have content) 43 | import type { Delta } from './generated/openai/index.js'; 44 | export type StreamingDelta = Partial; 45 | 46 | // ============================================================ 47 | // REQUEST TYPES (Manual - cannot be auto-generated) 48 | // ============================================================ 49 | 50 | export interface OpenAIFunction { 51 | name: string; 52 | description?: string; 53 | parameters: { 54 | type: 'object'; 55 | properties: Record; 56 | required?: string[]; 57 | }; 58 | } 59 | 60 | export interface OpenAITool { 61 | type: 'function'; 62 | function: OpenAIFunction; 63 | } 64 | 65 | /** 66 | * Message content can be either a simple string or an array of content parts. 67 | * Array format supports multimodal content (text, images, etc.) as per OpenAI spec. 68 | */ 69 | export type OpenAIMessageContent = string | null | Array<{ 70 | type: 'text' | 'image_url'; 71 | text?: string; 72 | image_url?: { 73 | url: string; 74 | detail?: 'low' | 'high' | 'auto'; 75 | }; 76 | [key: string]: unknown; 77 | }>; 78 | 79 | export interface OpenAIMessage { 80 | role: 'system' | 'user' | 'assistant' | 'tool'; 81 | content: OpenAIMessageContent; 82 | name?: string; 83 | tool_calls?: Array<{ 84 | id: string; 85 | type: 'function'; 86 | function: { 87 | name: string; 88 | arguments: string; // JSON string 89 | }; 90 | }>; 91 | tool_call_id?: string; 92 | } 93 | 94 | export interface OpenAIRequest { 95 | model: string; 96 | messages: OpenAIMessage[]; 97 | tools?: OpenAITool[]; 98 | tool_choice?: 'none' | 'auto' | { type: 'function'; function: { name: string } }; 99 | temperature?: number; 100 | top_p?: number; 101 | max_tokens?: number; 102 | stream?: boolean; 103 | stop?: string | string[]; 104 | // Advanced options (not all providers support these) 105 | response_format?: { type: 'json_object' | 'text' } | { type: 'json_schema'; json_schema?: unknown }; 106 | stream_options?: { include_usage?: boolean }; 107 | logprobs?: boolean; 108 | top_logprobs?: number; 109 | seed?: number; 110 | n?: number; 111 | user?: string; 112 | presence_penalty?: number; 113 | frequency_penalty?: number; 114 | // Legacy support 115 | functions?: OpenAIFunction[]; 116 | function_call?: 'none' | 'auto' | { name: string }; 117 | [key: string]: unknown; 118 | } 119 | -------------------------------------------------------------------------------- /src/test/regression/html-buffer-overflow.test.ts: -------------------------------------------------------------------------------- 1 | import assert from "assert"; 2 | 3 | import { describe, it } from "mocha"; 4 | 5 | import { attemptPartialToolCallExtraction } from "../../parsers/xml/index.js"; 6 | 7 | import type { PartialExtractionResult } from "../../types/index.js"; 8 | 9 | describe("HTML Buffer Overflow Regression Tests", () => { 10 | const knownTools: string[] = ["insert_edit_into_file", "search", "run_in_terminal"]; 11 | 12 | it("should not buffer indefinitely when encountering HTML closing tags", () => { 13 | const closingTag = ""; 14 | const regularContent = 15 | " This is some regular content that follows an HTML tag."; 16 | 17 | const initialResult: PartialExtractionResult = attemptPartialToolCallExtraction( 18 | closingTag, 19 | knownTools, 20 | ); 21 | assert.strictEqual( 22 | initialResult.partialState?.buffer, 23 | "", 24 | "Buffer should be empty after encountering a closing HTML tag", 25 | ); 26 | 27 | const nextResult: PartialExtractionResult = attemptPartialToolCallExtraction( 28 | closingTag + regularContent, 29 | knownTools, 30 | ); 31 | assert.strictEqual( 32 | nextResult.partialState?.buffer, 33 | "", 34 | "Buffer should remain empty when adding content after a closing HTML tag", 35 | ); 36 | 37 | const longContent = closingTag + " " + "x".repeat(5000); 38 | const longResult: PartialExtractionResult = attemptPartialToolCallExtraction( 39 | longContent, 40 | knownTools, 41 | ); 42 | assert.strictEqual( 43 | longResult.partialState?.buffer, 44 | "", 45 | "Buffer should remain empty even with long content after a closing HTML tag", 46 | ); 47 | }); 48 | 49 | it("should still detect tool calls after HTML content", () => { 50 | const htmlContent = 51 | "
Some HTML content
"; 52 | const toolCall = 53 | "Test/test.jsconsole.log('test');"; 54 | 55 | const fullContent = htmlContent + toolCall; 56 | const fullResult: PartialExtractionResult = attemptPartialToolCallExtraction( 57 | fullContent, 58 | knownTools, 59 | ); 60 | 61 | assert.strictEqual( 62 | fullResult.complete, 63 | true, 64 | "Should detect tool call after HTML content", 65 | ); 66 | assert.strictEqual( 67 | fullResult.toolCall?.name, 68 | "insert_edit_into_file", 69 | "Tool name should be correctly extracted", 70 | ); 71 | }); 72 | 73 | it("should handle the specific regression case with growing buffer", () => { 74 | const startContent = ""; 75 | 76 | attemptPartialToolCallExtraction(startContent, knownTools); 77 | 78 | const contentWithMoreText = startContent + "x".repeat(5000); 79 | const result: PartialExtractionResult = attemptPartialToolCallExtraction( 80 | contentWithMoreText, 81 | knownTools, 82 | ); 83 | 84 | assert.strictEqual( 85 | result.partialState?.buffer, 86 | "", 87 | `Buffer should be empty after ${contentWithMoreText.length} chars`, 88 | ); 89 | 90 | const contentWithToolCall = 91 | contentWithMoreText + 92 | "Fix/test.jstest"; 93 | const finalResult: PartialExtractionResult = attemptPartialToolCallExtraction( 94 | contentWithToolCall, 95 | knownTools, 96 | ); 97 | 98 | assert.strictEqual( 99 | finalResult.complete, 100 | true, 101 | "Should detect tool call after large non-tool content", 102 | ); 103 | assert.strictEqual( 104 | finalResult.toolCall?.name, 105 | "insert_edit_into_file", 106 | "Tool name should be correctly extracted", 107 | ); 108 | }); 109 | }); -------------------------------------------------------------------------------- /src/test/utils/retryHelpers.ts: -------------------------------------------------------------------------------- 1 | const DEFAULT_BASE_DELAY_MS = 500; 2 | const DEFAULT_MAX_DELAY_MS = 10000; 3 | 4 | const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); 5 | 6 | const computeDelay = (attempt: number, base: number, max: number): number => { 7 | const delay = base * (2 ** attempt); 8 | return Math.min(delay, max); 9 | }; 10 | 11 | export interface RetryOptions { 12 | maxRetries?: number; 13 | baseDelayMs?: number; 14 | maxDelayMs?: number; 15 | shouldRetry?: (error: unknown, attempt: number) => boolean | Promise; 16 | onRetry?: (error: unknown, attempt: number, delayMs: number) => void | Promise; 17 | } 18 | 19 | export async function retryWithBackoff( 20 | operation: () => Promise, 21 | options: RetryOptions = {} 22 | ): Promise { 23 | const { 24 | maxRetries = 5, 25 | baseDelayMs = DEFAULT_BASE_DELAY_MS, 26 | maxDelayMs = DEFAULT_MAX_DELAY_MS, 27 | shouldRetry, 28 | onRetry, 29 | } = options; 30 | 31 | let attempt = 0; 32 | 33 | for (;;) { 34 | try { 35 | return await operation(); 36 | } catch (error: unknown) { 37 | const canRetry = attempt < maxRetries; 38 | const allowRetry = canRetry && (shouldRetry ? await shouldRetry(error, attempt) : true); 39 | 40 | if (!allowRetry) { 41 | if (error instanceof Error) { 42 | throw error; 43 | } 44 | throw new Error(String(error)); 45 | } 46 | 47 | const delayMs = computeDelay(attempt, baseDelayMs, maxDelayMs); 48 | if (onRetry) { 49 | await onRetry(error, attempt, delayMs); 50 | } 51 | await sleep(delayMs); 52 | attempt += 1; 53 | } 54 | } 55 | } 56 | 57 | export interface FetchRetryOptions { 58 | maxRetries?: number; 59 | baseDelayMs?: number; 60 | maxDelayMs?: number; 61 | shouldRetryStatus?: (status: number) => boolean; 62 | } 63 | 64 | function parseRetryAfter(header: string | null): number | null { 65 | if (!header) { 66 | return null; 67 | } 68 | 69 | const numericValue = Number(header); 70 | if (Number.isFinite(numericValue)) { 71 | return Math.max(0, Math.floor(numericValue * 1000)); 72 | } 73 | 74 | const dateValue = Date.parse(header); 75 | if (!Number.isNaN(dateValue)) { 76 | return Math.max(0, dateValue - Date.now()); 77 | } 78 | 79 | return null; 80 | } 81 | 82 | export async function fetchWithRetry( 83 | url: string, 84 | init: RequestInit, 85 | options: FetchRetryOptions = {} 86 | ): Promise { 87 | const { 88 | maxRetries = 2, 89 | baseDelayMs = DEFAULT_BASE_DELAY_MS, 90 | maxDelayMs = 3100, 91 | shouldRetryStatus = (status: number) => status === 429, 92 | } = options; 93 | 94 | let attempt = 0; 95 | 96 | for (;;) { 97 | const response = await fetch(url, init); 98 | 99 | if (!shouldRetryStatus(response.status) || attempt >= maxRetries) { 100 | return response; 101 | } 102 | 103 | const retryAfterMs = parseRetryAfter(response.headers.get("retry-after")); 104 | const delayMs = retryAfterMs ?? computeDelay(attempt, baseDelayMs, maxDelayMs); 105 | await sleep(delayMs); 106 | attempt += 1; 107 | } 108 | } 109 | 110 | 111 | 112 | export function isRateLimitError(error: unknown): boolean { 113 | if (!error || typeof error !== "object") { 114 | const message = error instanceof Error ? error.message : String(error ?? ""); 115 | return message.includes("429") || /rate limit/i.test(message); 116 | } 117 | 118 | const candidate = error as { status?: number; message?: string }; 119 | if (typeof candidate.status === "number" && candidate.status === 429) { 120 | return true; 121 | } 122 | 123 | const message = candidate.message ?? (error instanceof Error ? error.message : undefined); 124 | if (typeof message === "string") { 125 | return message.includes("429") || /rate limit/i.test(message); 126 | } 127 | 128 | return false; 129 | } 130 | -------------------------------------------------------------------------------- /src/test/runners/run-llm-pattern-tests.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | import * as fs from "fs"; 3 | import * as path from "path"; 4 | 5 | interface TestResult { 6 | file: string; 7 | passed?: number; 8 | total?: number; 9 | percentage?: number; 10 | error?: boolean; 11 | errorMessage?: string; 12 | } 13 | 14 | interface TestResults { 15 | total: number; 16 | passed: number; 17 | failed: number; 18 | tests: TestResult[]; 19 | } 20 | 21 | const testDir = path.join(process.cwd(), "src", "test"); 22 | const llmPatternsDir = path.join(testDir, "parser", "llm-patterns"); 23 | const testResults: TestResults = { 24 | total: 0, 25 | passed: 0, 26 | failed: 0, 27 | tests: [], 28 | }; 29 | 30 | console.log("=== LLM Output Pattern Tests ==="); 31 | console.log("Testing how the parser handles realistic LLM output patterns\n"); 32 | 33 | let testFiles: string[] = []; 34 | try { 35 | testFiles = fs 36 | .readdirSync(llmPatternsDir) 37 | .filter((file: string) => file.endsWith(".test.js") || file.endsWith(".test.ts")) 38 | .map((file: string) => path.join("parser", "llm-patterns", file)); 39 | } catch (err: unknown) { 40 | const error = err instanceof Error ? err : new Error(String(err)); 41 | console.error(`Error reading directory ${llmPatternsDir}:`, error.message); 42 | process.exit(1); 43 | } 44 | 45 | if (testFiles.length === 0) { 46 | console.log("No test files found!"); 47 | process.exit(0); 48 | } 49 | 50 | console.log(`Found ${testFiles.length} test file(s):\n`); 51 | 52 | for (const relativeFilePath of testFiles) { 53 | const filePath = path.join(testDir, relativeFilePath); 54 | const displayPath = relativeFilePath; 55 | 56 | console.log(`Running test: ${displayPath}...`); 57 | try { 58 | const output = execSync(`node ${filePath}`, { encoding: "utf-8" }); 59 | 60 | const resultMatch = output.match( 61 | /FINAL RESULTS: (\d+)\/(\d+) tests passed/, 62 | ); 63 | 64 | if (resultMatch) { 65 | const passed = parseInt(resultMatch[1] || '0', 10); 66 | const total = parseInt(resultMatch[2] || '0', 10); 67 | const failed = total - passed; 68 | 69 | testResults.total += total; 70 | testResults.passed += passed; 71 | testResults.failed += failed; 72 | 73 | const passingPercentage = Math.round((passed / total) * 100); 74 | 75 | testResults.tests.push({ 76 | file: displayPath, 77 | passed, 78 | total, 79 | percentage: passingPercentage, 80 | }); 81 | 82 | console.log(` ${passingPercentage}% passed (${passed}/${total})`); 83 | } else { 84 | console.log(` ⚠️ Could not parse test results from output`); 85 | } 86 | } catch (error: unknown) { 87 | const err = error instanceof Error ? error : new Error(String(error)); 88 | console.error( 89 | ` ❌ Error running test file ${displayPath}:`, 90 | err.message, 91 | ); 92 | testResults.tests.push({ 93 | file: displayPath, 94 | error: true, 95 | errorMessage: err.message, 96 | }); 97 | } 98 | } 99 | 100 | console.log("\n=== LLM Pattern Test Results Summary ==="); 101 | console.log(`Total tests: ${testResults.total}`); 102 | console.log(`Passed: ${testResults.passed}`); 103 | console.log(`Failed: ${testResults.failed}`); 104 | 105 | const overallPercentage = 106 | testResults.total > 0 107 | ? Math.round((testResults.passed / testResults.total) * 100) 108 | : 0; 109 | 110 | console.log(`Overall passing rate: ${overallPercentage}%`); 111 | 112 | console.log("\nDetailed Results:"); 113 | for (const result of testResults.tests) { 114 | if (result.error === true) { 115 | console.log(`❌ ${result.file}: ERROR - ${result.errorMessage}`); 116 | } else { 117 | const icon = 118 | (result.percentage ?? 0) === 100 ? "✅" : (result.percentage ?? 0) >= 80 ? "⚠️" : "❌"; 119 | console.log( 120 | `${icon} ${result.file}: ${result.percentage}% (${result.passed}/${result.total})`, 121 | ); 122 | } 123 | } 124 | 125 | process.exit(testResults.failed > 0 ? 1 : 0); -------------------------------------------------------------------------------- /src/parsers/xml/utils/jsonFallback.ts: -------------------------------------------------------------------------------- 1 | 2 | import { logger } from "../../../logging/index.js"; 3 | import type { ExtractedToolCall } from "../../../types/index.js"; 4 | 5 | /** 6 | * Extract balanced JSON object from text starting at a specific index 7 | */ 8 | function extractBalancedJSON(text: string, startIndex: number): string | null { 9 | let braceCount = 0; 10 | let inString = false; 11 | let escape = false; 12 | let started = false; 13 | let endIndex = -1; 14 | 15 | for (let i = startIndex; i < text.length; i++) { 16 | const char = text[i]; 17 | 18 | if (escape) { 19 | escape = false; 20 | continue; 21 | } 22 | 23 | if (char === '\\') { 24 | escape = true; 25 | continue; 26 | } 27 | 28 | if (char === '"' && !escape) { 29 | inString = !inString; 30 | continue; 31 | } 32 | 33 | if (!inString) { 34 | if (char === '{') { 35 | braceCount++; 36 | started = true; 37 | } else if (char === '}') { 38 | braceCount--; 39 | if (started && braceCount === 0) { 40 | endIndex = i + 1; 41 | break; 42 | } 43 | } 44 | } 45 | } 46 | 47 | if (endIndex !== -1) { 48 | return text.substring(startIndex, endIndex); 49 | } 50 | return null; 51 | } 52 | 53 | /** 54 | * Try to parse JSON-style tool call: toolName{"param":"value"} or toolName({"param":"value"}) 55 | * This handles smaller LLMs that output JSON instead of XML. 56 | */ 57 | export function parseJSONToolCall( 58 | text: string, 59 | knownToolNames: string[] 60 | ): ExtractedToolCall | null { 61 | if (!text || knownToolNames.length === 0) { 62 | return null; 63 | } 64 | 65 | // Pattern: toolName...{ 66 | for (const toolName of knownToolNames) { 67 | const escapedName = toolName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); 68 | 69 | // Find start of tool call 70 | // Matches: toolName { or toolName({ or toolName( { 71 | // We only care about finding the start index of the JSON object 72 | const startPattern = new RegExp(`${escapedName}\\s*(?:\\(?\\s*)?({)`, "i"); 73 | const match = text.match(startPattern); 74 | 75 | if (match && match.index !== undefined) { 76 | // Calculate start index of the brace. 77 | // match[0] is the full match, match[1] is the capturing group '{' 78 | // We need index of the '{' in the original text. 79 | // match.index is start of match. 80 | // We can search for '{' starting from match.index. 81 | const braceIndex = text.indexOf('{', match.index); 82 | 83 | if (braceIndex !== -1) { 84 | const jsonStr = extractBalancedJSON(text, braceIndex); 85 | 86 | if (jsonStr) { 87 | try { 88 | // Clean up JSON if mainly valid but has minor issues 89 | const cleanedJson = jsonStr 90 | .replace(/'/g, '"') // Convert single quotes to double 91 | .replace(/(\w+):/g, '"$1":') // Add quotes around unquoted keys 92 | .replace(/,\s*}/g, "}"); // Remove trailing commas 93 | 94 | const args = JSON.parse(cleanedJson); 95 | logger.debug( 96 | `[JSON Fallback] Successfully extracted tool call "${toolName}" via JSON fallback` 97 | ); 98 | return { 99 | name: toolName, 100 | arguments: args, 101 | }; 102 | } catch { 103 | // JSON parse failed 104 | continue; 105 | } 106 | } 107 | } 108 | } 109 | } 110 | 111 | return null; 112 | } 113 | -------------------------------------------------------------------------------- /src/server/genericProxy.ts: -------------------------------------------------------------------------------- 1 | import { createProxyMiddleware } from "http-proxy-middleware"; 2 | 3 | import { BACKEND_LLM_BASE_URL } from "../config.js"; 4 | import { logger } from "../logging/index.js"; 5 | import { buildBackendHeaders } from "../utils/http/index.js"; 6 | import { logRequestDetails } from "../utils/http/proxyLogging.js"; 7 | import { 8 | buildProxyRequestInfo, 9 | collectBackendHeaders, 10 | getOriginalUrl, 11 | type ProxyResponse, 12 | } from "../utils/http/proxyUtils.js"; 13 | 14 | import type { Request } from "express"; 15 | import type { ClientRequest, IncomingMessage, ServerResponse } from "http"; 16 | 17 | const proxyOptions = { 18 | target: BACKEND_LLM_BASE_URL, 19 | changeOrigin: true, 20 | 21 | pathRewrite: (path: string, req: IncomingMessage): string => { 22 | // Path already stripped of /v1 by Express, pass through as-is 23 | // BACKEND_LLM_BASE_URL already includes /api/v1, so we just need the endpoint path 24 | const original = getOriginalUrl(req, path); 25 | logger.debug(`\n[PROXY] Passing through path: ${original} -> ${path}`); 26 | return path; 27 | }, 28 | 29 | on: { 30 | proxyReq: (proxyReq: ClientRequest, req: IncomingMessage, _res: ServerResponse): void => { 31 | const expressReq = req as Request; 32 | logRequestDetails( 33 | "CLIENT REQUEST", 34 | { 35 | method: expressReq.method, 36 | headers: expressReq.headers, 37 | body: expressReq.body, 38 | ip: expressReq.ip, 39 | originalUrl: expressReq.originalUrl, 40 | path: expressReq.path, 41 | }, 42 | expressReq.headers, 43 | expressReq.body, 44 | ); 45 | 46 | const clientAuthHeader = expressReq.headers["authorization"]; 47 | const backendHeaders = buildBackendHeaders(clientAuthHeader, expressReq.headers, "proxy"); 48 | 49 | Object.keys(backendHeaders).forEach((key) => { 50 | const value = backendHeaders[key]; 51 | if (value !== undefined) { 52 | proxyReq.setHeader(key, value); 53 | } 54 | }); 55 | 56 | const backendUrl = `${BACKEND_LLM_BASE_URL}${proxyReq.path}`; 57 | const actualBackendHeaders = collectBackendHeaders(proxyReq); 58 | const proxyRequestInfo = buildProxyRequestInfo(expressReq, backendUrl, expressReq.body); 59 | logRequestDetails("PROXY REQUEST", proxyRequestInfo, actualBackendHeaders, expressReq.body); 60 | }, 61 | 62 | proxyRes: (proxyRes: ProxyResponse, req: IncomingMessage, res: ServerResponse): void => { 63 | const expressReq = req as Request; 64 | const contentType = proxyRes.headers["content-type"]; 65 | logger.debug( 66 | `[PROXY RESPONSE] Status: ${proxyRes.statusCode} (${contentType ?? "N/A"}) for ${expressReq.method} ${expressReq.originalUrl}`, 67 | ); 68 | logger.debug(`[PROXY RESPONSE] Headers received from backend:`); 69 | logger.debug(JSON.stringify(proxyRes.headers, null, 2)); 70 | 71 | if (typeof contentType === "string" && contentType.includes("text/event-stream")) { 72 | res.setHeader("Content-Type", "text/event-stream"); 73 | } 74 | 75 | // Note: Do not consume the proxyRes stream here as it breaks passthrough 76 | // The proxy middleware handles forwarding the response to the client 77 | }, 78 | 79 | error: (err: Error & { code?: string }, _req: IncomingMessage, res: ServerResponse): void => { 80 | logger.error("Proxy error:", err); 81 | 82 | if (!res.headersSent) { 83 | if (err.code === "ECONNREFUSED") { 84 | res.statusCode = 503; 85 | res.end(`Service Unavailable: Cannot connect to backend at ${BACKEND_LLM_BASE_URL}`); 86 | } else { 87 | res.statusCode = 502; 88 | res.end(`Proxy Error: ${err.message}`); 89 | } 90 | } else if (!res.writableEnded) { 91 | res.end(); 92 | } 93 | }, 94 | }, 95 | }; 96 | 97 | const genericProxy = createProxyMiddleware(proxyOptions as Parameters[0]); 98 | 99 | export default genericProxy; 100 | -------------------------------------------------------------------------------- /src/test/parser/html/contentValidation.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { describe, it } from "mocha"; 3 | 4 | import { detectPotentialToolCall } from "../../../handlers/toolCallHandler.js"; 5 | import { extractToolCall } from "../../../parsers/xml/index.js"; 6 | 7 | import type { ToolCallDetectionResult, ExtractedToolCall } from "../../../types/index.js"; 8 | 9 | interface TestCase { 10 | name: string; 11 | content: string; 12 | } 13 | 14 | describe("Testing Tool Call Validation with Problematic Content in Parameters", function () { 15 | const knownTools: string[] = [ 16 | "insert_edit_into_file", 17 | "read_file", 18 | "run_in_terminal", 19 | "create_file", 20 | ]; 21 | 22 | const testCases: TestCase[] = [ 23 | { 24 | name: "HTML doctype and structure", 25 | content: ` 26 | Add HTML content 27 | /path/to/file.html 28 | 29 | 30 | 31 | 32 | 33 | Document 34 | 35 | 36 |

Hello World

37 | 38 |
39 |
`, 40 | }, 41 | 42 | { 43 | name: "HTML with comments and invalid XML structure", 44 | content: ` 45 | Add HTML with comments 46 | /test/file.html 47 | 48 |
49 |

This is a paragraph with a self-closing tag
and unclosed angle brackets size < 10

50 |
51 |
`, 52 | }, 53 | 54 | { 55 | name: "JavaScript code with angle brackets", 56 | content: ` 57 | Add JavaScript 58 | /test/file.js 59 | function compareValues(a, b) { 60 | if (a < b) { 61 | return -1; 62 | } else if (a > b) { 63 | return 1; 64 | } 65 | return 0; 66 | } 67 | `, 68 | }, 69 | 70 | { 71 | name: "Python code with angle brackets", 72 | content: ` 73 | Add Python code 74 | /test/file.py 75 | def filter_items(items): 76 | return [i for i in items if i < 10 and i > 0] 77 | 78 | # Testing with list comprehension 79 | filtered = [x for x in range(20) if x < 15] 80 | `, 81 | }, 82 | 83 | { 84 | name: "XML content inside tool parameter", 85 | content: ` 86 | /test/config.xml 87 | 88 | 89 | 90 | 91 | 92 | 93 | AutoSave 94 | 300 95 | 96 | 97 | 98 | `, 99 | }, 100 | ]; 101 | 102 | testCases.forEach((testCase) => { 103 | it(`should properly handle ${testCase.name}`, function () { 104 | const detection: ToolCallDetectionResult = detectPotentialToolCall(testCase.content, knownTools); 105 | expect(detection).to.not.be.null; 106 | expect(detection.mightBeToolCall).to.be.true; 107 | 108 | try { 109 | const parsedResult: ExtractedToolCall | null = extractToolCall(testCase.content); 110 | 111 | if (parsedResult) { 112 | const args = parsedResult.arguments; 113 | const hasRequiredParams = Object.keys(args).length > 0; 114 | expect(hasRequiredParams).to.be.true; 115 | } else { 116 | console.log( 117 | `XML parsing failed for ${testCase.name} - this is expected for HTML content`, 118 | ); 119 | } 120 | } catch (_error: unknown) { 121 | console.log( 122 | `XML parsing threw error for ${testCase.name} - this is expected for HTML content`, 123 | ); 124 | } 125 | }); 126 | }); 127 | }); -------------------------------------------------------------------------------- /src/types/toolbridge.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * ToolBridge Core Types 3 | */ 4 | 5 | import type { OpenAITool } from './openai.js'; 6 | 7 | export type RequestFormat = 'openai' | 'ollama'; 8 | 9 | export interface ToolCallDetectionResult { 10 | isPotential: boolean; 11 | isCompletedXml: boolean; 12 | rootTagName: string | null; 13 | confidence: number; 14 | mightBeToolCall: boolean; 15 | } 16 | 17 | export interface ExtractedToolCall { 18 | name: string; 19 | arguments: Record | string; 20 | } 21 | 22 | export interface BackendPayload { 23 | model: string; 24 | messages?: Array<{ 25 | role: string; 26 | content: string; 27 | }>; 28 | prompt?: string; // For Ollama format 29 | temperature?: number; 30 | top_p?: number; 31 | max_tokens?: number; 32 | stream?: boolean; 33 | tools?: OpenAITool[]; 34 | tool_choice?: unknown; 35 | functions?: unknown; 36 | function_call?: unknown; 37 | options?: Record; // For Ollama options 38 | template?: string; // For Ollama template 39 | [key: string]: unknown; 40 | } 41 | 42 | export interface StreamProcessor { 43 | res?: import('express').Response | undefined; 44 | processChunk(chunk: Buffer | string): void | Promise; 45 | setTools?(tools: OpenAITool[]): void; 46 | setStreamOptions?(options?: { include_usage?: boolean }): void; 47 | handleDone?(): void; 48 | end(): void; 49 | closeStream?(message?: string | null): void; 50 | closeStreamWithError?(errorMessage: string): void; 51 | pipeFrom?(stream: NodeJS.ReadableStream): void; 52 | } 53 | 54 | export interface WrapperAwareStreamProcessor extends StreamProcessor { 55 | buffer: string; 56 | inWrapper: boolean; 57 | wrapperContent: string; 58 | beforeWrapperContent: string; 59 | knownToolNames: string[]; 60 | unwrappedBuffer: string; 61 | checkingUnwrapped: boolean; 62 | originalProcessor: StreamProcessor; 63 | } 64 | 65 | export interface ToolCallHandlerConfig { 66 | enableToolReinjection: boolean; 67 | toolReinjectionMessageCount: number; 68 | toolReinjectionTokenCount: number; 69 | toolReinjectionType: 'system' | 'user'; 70 | } 71 | 72 | export interface ProxyConfig { 73 | proxyPort: number; 74 | proxyHost: string; 75 | backendLlmBaseUrl: string; 76 | backendLlmApiKey: string; 77 | debugMode: boolean; 78 | maxStreamBufferSize: number; 79 | streamConnectionTimeout: number; 80 | ollamaBaseUrl?: string; 81 | ollamaDefaultContextLength: number; 82 | } 83 | 84 | // Error types 85 | export interface BackendError extends Error { 86 | status?: number; 87 | response?: { 88 | status: number; 89 | data: unknown; 90 | }; 91 | request?: unknown; 92 | } 93 | 94 | // Request context 95 | export interface RequestContext { 96 | clientRequestFormat: RequestFormat; 97 | backendTargetFormat: RequestFormat; 98 | originalTools: OpenAITool[]; 99 | clientRequestedStream: boolean; 100 | clientAuthHeader: string; 101 | clientHeaders: Record; 102 | } 103 | 104 | // XML parsing specific types 105 | export interface XMLParserStrategy { 106 | canHandle(text: string, toolName: string): boolean; 107 | extract(text: string, toolName: string): ExtractedToolCall | null; 108 | } 109 | 110 | export interface XMLParsingResult { 111 | success: boolean; 112 | toolCall: ExtractedToolCall | null; 113 | strategy: string; 114 | error?: string; 115 | } 116 | 117 | // Stream chunk types for internal processing 118 | export interface InternalStreamChunk { 119 | type: 'content' | 'tool_call' | 'done' | 'error'; 120 | content?: string; 121 | toolCall?: ExtractedToolCall; 122 | error?: string; 123 | metadata?: { 124 | chunkIndex: number; 125 | timestamp: number; 126 | }; 127 | } 128 | 129 | // Partial tool call extraction types 130 | export interface PartialToolCallState { 131 | rootTag: string | null; 132 | isPotential: boolean; 133 | mightBeToolCall: boolean; 134 | buffer: string; 135 | identifiedToolName: string | null; 136 | } 137 | 138 | export interface PartialExtractionResult { 139 | complete: boolean; 140 | toolCall?: ExtractedToolCall; 141 | content?: string; 142 | partialState?: PartialToolCallState; 143 | } -------------------------------------------------------------------------------- /src/test/performance/memoryUsage.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { describe, it } from "mocha"; 3 | 4 | import { OpenAISSEStreamProcessor } from "../../handlers/stream/processors/OpenAISSEStreamProcessor.js"; 5 | import { TEST_TIMEOUT_STANDARD } from "../../test/utils/testConfig.js"; 6 | 7 | import type { Response } from "express"; 8 | 9 | // align with NodeJS type for gc 10 | declare global { 11 | var gc: NodeJS.GCFunction | undefined; 12 | } 13 | 14 | interface MockResponse { 15 | write: () => void; 16 | end: () => void; 17 | setHeader: () => void; 18 | headersSent: boolean; 19 | writableEnded: boolean; 20 | } 21 | 22 | describe("Memory Usage Tests", function () { 23 | this.timeout(TEST_TIMEOUT_STANDARD); 24 | 25 | it("should not leak memory when processing large streams", function () { 26 | const mockRes: MockResponse = { 27 | write: () => { }, 28 | end: () => { }, 29 | setHeader: () => { }, 30 | headersSent: false, 31 | writableEnded: false, 32 | }; 33 | 34 | const processor = new OpenAISSEStreamProcessor(mockRes as unknown as Response); 35 | processor.setTools([ 36 | { type: 'function', function: { name: "search", parameters: { type: 'object', properties: {} } } }, 37 | { type: 'function', function: { name: "run_code", parameters: { type: 'object', properties: {} } } }, 38 | { type: 'function', function: { name: "analyze", parameters: { type: 'object', properties: {} } } }, 39 | ]); 40 | 41 | const initialMemory = process.memoryUsage().heapUsed; 42 | 43 | for (let i = 0; i < 5000; i++) { 44 | const contentType = i % 100; 45 | let content: string; 46 | 47 | if (contentType === 0) { 48 | content = ``; 49 | } else if (contentType === 50) { 50 | content = ``; 51 | } else if (contentType === 25) { 52 | content = `Finding memory leaks in Node.js applications`; 53 | } else if (contentType === 75) { 54 | content = `I'm thinking about how to solve this problem...`; 55 | } else if (contentType === 10) { 56 | content = `${"This is some content that would be generated by an LLM. ".repeat(5)}`; 57 | } else { 58 | content = `content chunk ${i}`; 59 | } 60 | 61 | const chunk = `{"id":"chunk${i}","choices":[{"delta":{"content":"${content}"}}]}`; 62 | processor.processChunk(Buffer.from(chunk)); 63 | 64 | if (i % 1000 === 0 && i > 0) { 65 | const currentMemory = process.memoryUsage().heapUsed; 66 | const memoryGrowthMB = (currentMemory - initialMemory) / (1024 * 1024); 67 | console.log( 68 | `Memory growth after ${i} chunks: ${memoryGrowthMB.toFixed(2)}MB`, 69 | ); 70 | } 71 | } 72 | 73 | processor.end(); 74 | 75 | const finalMemory = process.memoryUsage().heapUsed; 76 | const memoryGrowthMB = (finalMemory - initialMemory) / (1024 * 1024); 77 | console.log(`Total memory growth: ${memoryGrowthMB.toFixed(2)}MB`); 78 | 79 | expect(memoryGrowthMB).to.be.below(100); 80 | 81 | if (global.gc) { 82 | global.gc(); 83 | console.log("Garbage collection triggered"); 84 | } 85 | }); 86 | 87 | it("should handle buffer flushing under memory pressure", function () { 88 | const mockRes: MockResponse = { 89 | write: () => { }, 90 | end: () => { }, 91 | setHeader: () => { }, 92 | headersSent: false, 93 | writableEnded: false, 94 | }; 95 | 96 | const processor = new OpenAISSEStreamProcessor(mockRes as unknown as Response); 97 | processor.setTools([{ type: 'function', function: { name: "search", parameters: { type: 'object', properties: {} } } }]); 98 | 99 | const largeChunk = { 100 | id: "large-chunk", 101 | choices: [ 102 | { 103 | delta: { 104 | content: `${"A very long text that would normally exceed buffer limits. ".repeat(1000)}${"A very long search query. ".repeat(50)}`, 105 | }, 106 | }, 107 | ], 108 | }; 109 | 110 | processor.processChunk(Buffer.from(JSON.stringify(largeChunk))); 111 | processor.end(); 112 | 113 | }); 114 | }); -------------------------------------------------------------------------------- /src/test/parser/xml/debugTool.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { after, before, describe, it } from "mocha"; 3 | 4 | import { detectPotentialToolCall } from "../../../handlers/toolCallHandler.js"; 5 | import { logger } from "../../../logging/index.js"; 6 | import { extractToolCall } from "../../../parsers/xml/index.js"; 7 | 8 | import type { ToolCallDetectionResult, ExtractedToolCall } from "../../../types/index.js"; 9 | 10 | describe("Debug Tool XML Extraction Tests", function () { 11 | before(function () { 12 | (logger as unknown as { level: string }).level = "debug"; 13 | }); 14 | 15 | after(function () { 16 | (logger as unknown as { level: string }).level = "info"; 17 | }); 18 | 19 | const htmlToolCall = ` 20 | Add HTML content to index page 21 | /Users/m3hdi/my-project/index.html 22 | 23 | 24 | 25 | 26 | 27 | My Website 28 | 29 | 30 | 31 |

Welcome to My Website

32 |

This is an example of HTML content with < and > characters.

33 |
34 |
    35 |
  • Item 1
  • 36 |
  • Item 2
  • 37 |
  • Item with x < 10 condition
  • 38 |
39 |
40 | 41 |
42 |
`; 43 | 44 | it("should extract tool call with HTML content correctly", function () { 45 | const knownToolNames: string[] = ["insert_edit_into_file", "create_file"]; 46 | 47 | const detection: ToolCallDetectionResult = detectPotentialToolCall(htmlToolCall, knownToolNames); 48 | expect(detection).to.not.be.null; 49 | expect(detection.isPotential).to.be.true; 50 | expect(detection.mightBeToolCall).to.be.true; 51 | expect(detection.rootTagName).to.equal("insert_edit_into_file"); 52 | 53 | try { 54 | const safeHtmlToolCall = htmlToolCall 55 | .replace( 56 | "", 57 | "", 58 | ) 59 | .replace( 60 | "
  • Item with x < 10 condition
  • ", 61 | "
  • Item with x < 10 condition
  • ", 62 | ); 63 | 64 | const parsed: ExtractedToolCall | null = extractToolCall(safeHtmlToolCall, [ 65 | "insert_edit_into_file", 66 | ]); 67 | 68 | expect(parsed).to.not.be.null; 69 | expect(parsed).to.not.be.null; 70 | const p = parsed as ExtractedToolCall; 71 | expect(p.name).to.equal("insert_edit_into_file"); 72 | expect(p.arguments).to.be.an("object"); 73 | expect((p.arguments as Record)['explanation']).to.equal( 74 | "Add HTML content to index page", 75 | ); 76 | const parsedP = parsed as ExtractedToolCall; 77 | expect((parsedP.arguments as Record)['filePath']).to.equal( 78 | "/Users/m3hdi/my-project/index.html", 79 | ); 80 | expect((parsedP.arguments as Record)['code']).to.include(""); 81 | } catch (_err: unknown) { 82 | console.log( 83 | "XML parsing issues are expected for HTML content - detection still worked", 84 | ); 85 | } 86 | }); 87 | 88 | it("should handle XML with CDATA sections", function () { 89 | const xmlWithCDATA = ` 90 | Add JavaScript code 91 | /path/to/file.js 92 | b) { 97 | return 1; 98 | } 99 | return 0; 100 | } 101 | ]]> 102 | `; 103 | 104 | const parsed: ExtractedToolCall | null = extractToolCall(xmlWithCDATA, [ 105 | "insert_edit_into_file", 106 | ]); 107 | 108 | expect(parsed).to.not.be.null; 109 | const parsed2 = parsed as ExtractedToolCall; 110 | expect(parsed2.name).to.equal("insert_edit_into_file"); 111 | expect((parsed2.arguments as Record)['code']).to.include("if (a < b)"); 112 | }); 113 | }); -------------------------------------------------------------------------------- /src/test/challenges/generateTests.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { fileURLToPath } from "url"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = path.dirname(__filename); 7 | 8 | const challengesDir = __dirname; 9 | const generatedTestsDir = path.join( 10 | __dirname, 11 | "..", 12 | "parser", 13 | "generated-tests", 14 | ); 15 | 16 | interface Challenge { 17 | content: string; 18 | error?: { 19 | message: string; 20 | stack?: string; 21 | }; 22 | timestamp: number; 23 | } 24 | 25 | if (!fs.existsSync(generatedTestsDir)) { 26 | fs.mkdirSync(generatedTestsDir, { recursive: true }); 27 | } 28 | 29 | function generateTestFromChallenge(challengePath: string): void { 30 | try { 31 | const challengeContent = fs.readFileSync(challengePath, "utf8"); 32 | const challenge: Challenge = JSON.parse(challengeContent); 33 | 34 | const { content, error, timestamp } = challenge; 35 | 36 | if (!content) { 37 | console.log(`Skipping ${challengePath} - no content`); 38 | return; 39 | } 40 | 41 | const testFileName = `challenge_${timestamp}_test.js`; 42 | const testFilePath = path.join(generatedTestsDir, testFileName); 43 | 44 | const testFileContent = `// Auto-generated test from challenge captured at ${new Date(timestamp).toISOString()} 45 | import { expect } from "chai"; 46 | import { describe, it } from "mocha"; 47 | import { extractToolCall } from "../../../parsers/xml/index.js"; 48 | 49 | describe("Generated Challenge Test - ${new Date(timestamp).toISOString()}", function() { 50 | const knownToolNames = ["search", "run_code", "think", "replace_string_in_file", "insert_edit_into_file", "get_errors"]; 51 | 52 | it("should handle challenging XML pattern", function() { 53 | // This test was generated from a parsing challenge 54 | const challengingContent = ${JSON.stringify(content)}; 55 | 56 | ${error ? "// This content previously caused an error: " + JSON.stringify(error.message) : ""} 57 | 58 | // Test parsing the challenging content 59 | let result; 60 | let parseError; 61 | 62 | try { 63 | result = extractToolCall(challengingContent, knownToolNames); 64 | } catch (err) { 65 | parseError = err; 66 | } 67 | 68 | // We don't assert success/failure, but the parser shouldn't crash 69 | if (result) { 70 | expect(result).to.be.an('object'); 71 | if (result.name) { 72 | expect(result.name).to.be.a('string'); 73 | expect(knownToolNames).to.include(result.name); 74 | } 75 | } else if (parseError) { 76 | expect(parseError).to.be.an('error'); 77 | expect(parseError.message).to.be.a('string'); 78 | } 79 | }); 80 | });\n`; 81 | 82 | fs.writeFileSync(testFilePath, testFileContent); 83 | console.log(`Generated test file: ${testFilePath}`); 84 | } catch (_err: unknown) { 85 | const error = _err instanceof Error ? _err : new Error(String(_err)); 86 | console.error(`Failed to generate test from ${challengePath}:`, error); 87 | } 88 | } 89 | 90 | function processAllChallenges(): void { 91 | const files: string[] = fs 92 | .readdirSync(challengesDir) 93 | .filter((file: string) => file.startsWith("challenge-") && file.endsWith(".json")); 94 | 95 | if (files.length === 0) { 96 | console.log("No challenge files found"); 97 | return; 98 | } 99 | 100 | console.log(`Found ${files.length} challenge files`); 101 | 102 | files.forEach((file: string) => { 103 | generateTestFromChallenge(path.join(challengesDir, file)); 104 | }); 105 | 106 | const runnerContent = `// Auto-generated test runner for challenge tests 107 | import { describe } from "mocha"; 108 | 109 | describe("Generated Challenge Tests", function() { 110 | // Import all generated test files 111 | ${files 112 | .map((file: string) => { 113 | const testFileName = `challenge_${file.replace("challenge-", "").replace(".json", "")}_test.js`; 114 | return `require('./${testFileName}');`; 115 | }) 116 | .join("\n ")} 117 | });\n`; 118 | 119 | fs.writeFileSync( 120 | path.join(generatedTestsDir, "run-generated-tests.js"), 121 | runnerContent, 122 | ); 123 | console.log("Generated test runner"); 124 | } 125 | 126 | processAllChallenges(); 127 | -------------------------------------------------------------------------------- /src/handlers/stream/components/StateTracker.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * StateTracker - Stream State Management Component 3 | * 4 | * SSOT Compliance: Single source of truth for streaming state. 5 | * Extracted from formatConvertingStreamProcessor to follow KISS principle. 6 | * 7 | * Purpose: Track streaming state (tool calls, content emission, flags). 8 | * 9 | * Responsibilities: 10 | * - Track tool call state (in progress, completed) 11 | * - Track content emission state 12 | * - Track chunk counts 13 | * - Provide state queries and updates 14 | * 15 | * KISS Compliance: <120 lines, single responsibility, simple state machine 16 | */ 17 | 18 | import { logger } from "../../../logging/index.js"; 19 | 20 | /** 21 | * Stream state snapshot 22 | */ 23 | export interface StreamState { 24 | /** Whether a tool call is currently in progress */ 25 | isToolCallInProgress: boolean; 26 | /** Whether any content has been emitted */ 27 | hasEmittedContent: boolean; 28 | /** Current tool call name if in progress */ 29 | currentToolCall?: string; 30 | /** Total number of chunks processed */ 31 | chunkCount: number; 32 | /** Whether stream has ended */ 33 | streamEnded: boolean; 34 | /** Whether done signal has been sent */ 35 | doneSent: boolean; 36 | } 37 | 38 | /** 39 | * StateTracker manages streaming state 40 | */ 41 | export class StateTracker { 42 | private state: StreamState; 43 | 44 | constructor() { 45 | this.state = { 46 | isToolCallInProgress: false, 47 | hasEmittedContent: false, 48 | chunkCount: 0, 49 | streamEnded: false, 50 | doneSent: false, 51 | }; 52 | } 53 | 54 | /** 55 | * Mark tool call as started 56 | * @param toolName - Name of the tool being called 57 | */ 58 | startToolCall(toolName: string): void { 59 | this.state.isToolCallInProgress = true; 60 | this.state.currentToolCall = toolName; 61 | 62 | logger.debug(`[STATE TRACKER] Tool call started: ${toolName}`); 63 | } 64 | 65 | /** 66 | * Mark tool call as ended 67 | */ 68 | endToolCall(): void { 69 | const previousTool = this.state.currentToolCall; 70 | this.state.isToolCallInProgress = false; 71 | this.state.currentToolCall = undefined as any; 72 | 73 | logger.debug(`[STATE TRACKER] Tool call ended: ${previousTool}`); 74 | } 75 | 76 | /** 77 | * Record that a chunk was processed 78 | */ 79 | recordChunk(): void { 80 | this.state.chunkCount++; 81 | } 82 | 83 | /** 84 | * Record that content was emitted 85 | */ 86 | recordContent(): void { 87 | if (!this.state.hasEmittedContent) { 88 | this.state.hasEmittedContent = true; 89 | logger.debug("[STATE TRACKER] First content emitted"); 90 | } 91 | } 92 | 93 | /** 94 | * Mark stream as ended 95 | */ 96 | markStreamEnded(): void { 97 | this.state.streamEnded = true; 98 | logger.debug("[STATE TRACKER] Stream ended"); 99 | } 100 | 101 | /** 102 | * Mark done signal as sent 103 | */ 104 | markDoneSent(): void { 105 | this.state.doneSent = true; 106 | logger.debug("[STATE TRACKER] Done signal sent"); 107 | } 108 | 109 | /** 110 | * Get current state (readonly) 111 | * @returns Immutable copy of current state 112 | */ 113 | getState(): Readonly { 114 | return { ...this.state }; 115 | } 116 | 117 | /** 118 | * Check if tool call is in progress 119 | */ 120 | isToolCallActive(): boolean { 121 | return this.state.isToolCallInProgress; 122 | } 123 | 124 | /** 125 | * Check if any content has been emitted 126 | */ 127 | hasContent(): boolean { 128 | return this.state.hasEmittedContent; 129 | } 130 | 131 | /** 132 | * Check if stream has ended 133 | */ 134 | hasEnded(): boolean { 135 | return this.state.streamEnded; 136 | } 137 | 138 | /** 139 | * Check if done signal was sent 140 | */ 141 | isDoneSent(): boolean { 142 | return this.state.doneSent; 143 | } 144 | 145 | /** 146 | * Reset state (for reuse or testing) 147 | */ 148 | reset(): void { 149 | logger.debug("[STATE TRACKER] Resetting state"); 150 | 151 | this.state = { 152 | isToolCallInProgress: false, 153 | hasEmittedContent: false, 154 | chunkCount: 0, 155 | streamEnded: false, 156 | doneSent: false, 157 | }; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/services/contracts.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Service Layer Contracts 3 | * 4 | * Defines the interfaces between HTTP handlers and business logic. 5 | * All handlers MUST use these services instead of directly calling utilities. 6 | */ 7 | 8 | import type { LLMProvider } from '../translation/types/index.js'; 9 | import type { UniversalModel } from '../translation/types/models.js'; 10 | import type { TagsResponse, ShowResponse } from '../types/generated/ollama/index.js'; 11 | import type { ModelsListResponse, Datum as OpenAIModel } from '../types/generated/openai/models-list.js'; 12 | import type { OpenAITool, RequestFormat } from '../types/index.js'; 13 | import type { Readable } from 'stream'; 14 | 15 | /** 16 | * Request context extracted from HTTP layer 17 | */ 18 | export interface RequestContext { 19 | clientFormat: RequestFormat; 20 | backendFormat: RequestFormat; 21 | tools: OpenAITool[]; 22 | stream: boolean; 23 | authHeader?: string; 24 | headers: Record; 25 | } 26 | 27 | /** 28 | * Translation service - handles all format conversions 29 | */ 30 | export interface TranslationService { 31 | translateRequest( 32 | request: unknown, 33 | from: LLMProvider, 34 | to: LLMProvider, 35 | toolNames: string[] 36 | ): Promise; 37 | 38 | translateResponse( 39 | response: unknown, 40 | from: LLMProvider, 41 | to: LLMProvider, 42 | toolNames: string[] 43 | ): Promise; 44 | 45 | translateStream( 46 | stream: Readable, 47 | from: LLMProvider, 48 | to: LLMProvider, 49 | tools: OpenAITool[], 50 | streamOptions?: { include_usage?: boolean } 51 | ): Readable; 52 | } 53 | 54 | /** 55 | * Backend service - handles communication with LLM providers 56 | */ 57 | export interface BackendService { 58 | sendRequest( 59 | payload: unknown, 60 | stream: boolean, 61 | format: RequestFormat, 62 | provider: string, 63 | authHeader?: string, 64 | headers?: Record 65 | ): Promise; 66 | } 67 | 68 | /** 69 | * Configuration service - single source for all config 70 | */ 71 | export interface ConfigService { 72 | getBackendUrl(): string; 73 | getBackendApiKey(): string; 74 | getBackendMode(): 'openai' | 'ollama'; 75 | getServingMode(): 'openai' | 'ollama'; 76 | getOpenAIBackendUrl(): string; 77 | getOllamaBackendUrl(): string; 78 | detectBackendForModel(): 'openai' | 'ollama'; // returns explicitly configured backend 79 | getProxyPort(): number; 80 | getProxyHost(): string; 81 | isDebugMode(): boolean; 82 | shouldPassTools(): boolean; 83 | getToolReinjectionConfig(): { 84 | enabled: boolean; 85 | messageCount: number; 86 | tokenCount: number; 87 | type: 'system' | 'user'; 88 | }; 89 | } 90 | 91 | /** 92 | * Format detection service 93 | */ 94 | export interface FormatDetectionService { 95 | detectRequestFormat( 96 | body: unknown, 97 | headers: Record, 98 | url?: string 99 | ): RequestFormat; 100 | detectResponseFormat(response: unknown): RequestFormat; 101 | determineProvider(format: RequestFormat, url: string): 'openai' | 'ollama'; 102 | getProviderFromFormat(format: RequestFormat): LLMProvider; 103 | } 104 | 105 | /** 106 | * Model service - backend-agnostic model management with translation 107 | */ 108 | export interface ModelService { 109 | /** 110 | * List all models from the backend in the specified output format 111 | */ 112 | listModels(outputFormat: 'openai' | 'ollama', authHeader?: string): Promise; 113 | 114 | /** 115 | * Get model info in the specified output format 116 | */ 117 | getModelInfo(modelName: string, outputFormat: 'openai' | 'ollama', authHeader?: string): Promise; 118 | 119 | /** 120 | * Warm the model cache for the configured backend. 121 | * Used during startup to avoid repeated backend requests. 122 | */ 123 | preloadModelCache(authHeader?: string): Promise; 124 | 125 | /** 126 | * Get models in universal format (no translation) 127 | */ 128 | getUniversalModels(authHeader?: string): Promise; 129 | } 130 | -------------------------------------------------------------------------------- /src/parsers/xml/core/ParameterExtractor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Tool call parameter extraction from XML 3 | * Extracted from toolCallParser.ts for KISS compliance 4 | */ 5 | 6 | import { logger } from "../../../logging/index.js"; 7 | import { decodeCdataAndEntities } from "../utils/xmlCleaning.js"; 8 | import { extractNestedObject, parseValue } from "../utils/xmlValueParsing.js"; 9 | 10 | const RAW_TEXT_PARAMS = new Set(["code", "html", "markdown", "md", "body", "content"]); 11 | 12 | /** 13 | * Build arguments object from XML content 14 | * Handles: 15 | * - JSON-wrapped parameters 16 | * - Raw text parameters (code, html, markdown) 17 | * - Nested XML structures 18 | * - Multiple parameters with same name (arrays) 19 | */ 20 | export const buildArgumentsFromXml = ( 21 | xml: string, 22 | options: { 23 | rawToolNames?: Set; 24 | rootToolName?: string; 25 | } = {}, 26 | ): Record => { 27 | const params: Record = {}; 28 | const rootToolName = options.rootToolName; 29 | 30 | const contentRegex = rootToolName 31 | ? new RegExp(`<\\s*${rootToolName}[^>]*>([\\s\\S]*?)<\\/${rootToolName}>`, 'i') 32 | : null; 33 | 34 | const contentMatch = contentRegex ? contentRegex.exec(xml) : null; 35 | let content = contentMatch?.[1] ?? xml; 36 | 37 | // Try parsing as JSON first 38 | const trimmedContent = content.trim(); 39 | if (trimmedContent.startsWith('{') && trimmedContent.endsWith('}')) { 40 | try { 41 | const parsed = JSON.parse(trimmedContent); 42 | if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { 43 | return parsed as Record; 44 | } 45 | } catch { 46 | logger.debug("[XML Parser] JSON parsing failed; falling back to XML parsing"); 47 | } 48 | } 49 | 50 | // Extract all parameters using regex (respects proper XML nesting) 51 | // Regex allows optional attributes: content 52 | const paramRegex = /<([a-zA-Z0-9_.-]+)[^>]*>([\s\S]*?)<\/\1>/g; 53 | let match: RegExpExecArray | null; 54 | 55 | while ((match = paramRegex.exec(content)) !== null) { 56 | const paramName = match[1]; 57 | if (!paramName) { 58 | continue; 59 | } 60 | 61 | let paramValue: unknown = match[2]; 62 | const isRawToolName = options.rawToolNames?.has(paramName.toLowerCase()); 63 | const shouldPreserveForThink = 64 | options.rootToolName === "think" && 65 | (paramName.toLowerCase() === "points" || paramName.toLowerCase() === "thoughts"); 66 | 67 | if (RAW_TEXT_PARAMS.has(paramName.toLowerCase())) { 68 | // Raw text parameter - preserve as text but decode entities 69 | paramValue = decodeCdataAndEntities(match[2] as string); 70 | } else if (isRawToolName) { 71 | // Parameter name matches a known tool name - preserve as raw XML 72 | paramValue = match[2]; 73 | } else if (shouldPreserveForThink) { 74 | // Special handling for think tool - preserve points/thoughts as raw XML 75 | paramValue = match[2]; 76 | } else if (typeof paramValue === 'string' && paramValue.includes('<') && paramValue.includes('>')) { 77 | // Try to extract as nested XML object 78 | const nestedObj = extractNestedObject(paramValue); 79 | const hasValidChildren = Object.keys(nestedObj).length > 0; 80 | 81 | if (hasValidChildren) { 82 | // Successfully extracted nested structure 83 | // Special case: if nested object has only "item" key with array value, unwrap to array 84 | const nestedKeys = Object.keys(nestedObj); 85 | if (nestedKeys.length === 1 && nestedKeys[0] === "item" && Array.isArray(nestedObj['item'])) { 86 | paramValue = nestedObj['item']; 87 | } else { 88 | paramValue = nestedObj; 89 | } 90 | } else { 91 | // Malformed XML or intentional raw content - treat as text 92 | paramValue = decodeCdataAndEntities(paramValue); 93 | } 94 | } else if (typeof paramValue === 'string') { 95 | paramValue = parseValue(paramValue); 96 | } 97 | 98 | if (Object.prototype.hasOwnProperty.call(params, paramName)) { 99 | const existing = params[paramName]; 100 | if (Array.isArray(existing)) { 101 | existing.push(paramValue); 102 | } else { 103 | params[paramName] = [existing, paramValue]; 104 | } 105 | } else { 106 | params[paramName] = paramValue; 107 | } 108 | } 109 | 110 | return params; 111 | }; 112 | --------------------------------------------------------------------------------