Files
OpenQuery/docs/api/programmatic.md
OpenQuery Documentation 65ca2401ae docs: add comprehensive documentation with README and detailed guides
- Add user-friendly README.md with quick start guide
- Create docs/ folder with structured technical documentation:
  - installation.md: Build and setup instructions
  - configuration.md: Complete config reference
  - usage.md: CLI usage guide with examples
  - architecture.md: System design and patterns
  - components/: Deep dive into each component (OpenQueryApp, SearchTool, Services, Models)
  - api/: CLI reference, environment variables, programmatic API
  - troubleshooting.md: Common issues and solutions
  - performance.md: Latency, throughput, and optimization
- All documentation fully cross-referenced with internal links
- Covers project overview, architecture, components, APIs, and support

See individual files for complete documentation.
2026-03-19 10:01:58 +01:00

13 KiB

Programmatic API Reference

How to use OpenQuery components programmatically in your own C# code.

📋 Table of Contents

  1. Overview
  2. Using OpenQueryApp Programmatically
  3. Using Individual Services
  4. Custom Implementations
  5. Thread Safety
  6. Error Handling

Overview

OpenQuery is designed as a library of composable services, not just a CLI tool. You can reference the project (or extract the core classes) and use them in your own applications.

Core Interfaces

Currently, OpenQuery uses concrete classes rather than interfaces. To use programmatically:

  1. Reference the OpenQuery project/dll
  2. Add using OpenQuery.Services; and using OpenQuery.Tools;
  3. Instantiate dependencies
  4. Call methods

Dependency Chain

Your Code
  ├── OpenRouterClient (LLM API)
  ├── SearxngClient (Search API)
  ├── EmbeddingService (requires OpenRouterClient)
  └── SearchTool (requires SearxngClient + EmbeddingService)
      └── (internally uses ArticleService, ChunkingService, RateLimiter)

Using OpenQueryApp Programmatically

Minimal Example

using OpenQuery;
using OpenQuery.Services;
using OpenQuery.Tools;
using OpenQuery.Models;

// 1. Configure
string apiKey = Environment.GetEnvironmentVariable("OPENROUTER_API_KEY") 
    ?? throw new InvalidOperationException("API key required");
string searxngUrl = Environment.GetEnvironmentVariable("SEARXNG_URL") 
    ?? "http://localhost:8002";
string model = Environment.GetEnvironmentVariable("OPENROUTER_MODEL") 
    ?? "qwen/qwen3.5-flash-02-23";

// 2. Instantiate services
var openRouterClient = new OpenRouterClient(apiKey);
var searxngClient = new SearxngClient(searxngUrl);
var embeddingService = new EmbeddingService(openRouterClient);
var searchTool = new SearchTool(searxngClient, embeddingService);
var openQuery = new OpenQueryApp(openRouterClient, searchTool, model);

// 3. Execute
var options = new OpenQueryOptions(
    Chunks: 3,
    Results: 5,
    Queries: 3,
    Short: false,
    Long: false,
    Verbose: false,
    Question: "What is quantum entanglement?"
);

await openQuery.RunAsync(options);

Output: Streams answer to Console.Out (hardcoded in OpenQueryApp). To capture output, modify OpenQueryApp or redirect console.

Capturing Output

OpenQueryApp.RunAsync writes directly to Console. To capture:

Option 1: Redirect Console (hacky)

var sw = new StringWriter();
Console.SetOut(sw);
await openQuery.RunAsync(options);
string answer = sw.ToString();

Option 2: Modify OpenQueryApp to accept TextWriter (not currently supported)

Option 3: Reimplement using OpenQuery components without OpenQueryApp

public async Task<string> GetAnswerAsync(string question, OpenQueryOptions options)
{
    var sb = new StringBuilder();
    var reporter = new StatusReporter(options.Verbose);
    
    // Replicate OpenQueryApp.RunAsync but collect output
    // ... (copy logic from OpenQuery.cs)
    
    return sb.ToString();
}

Using Individual Services

OpenRouterClient

var client = new OpenRouterClient("your-api-key");

// Non-streaming chat completion
var request = new ChatCompletionRequest(
    model: "qwen/qwen3.5-flash-02-23",
    messages: new List<Message>
    {
        new Message("system", "You are a helpful assistant."),
        new Message("user", "What is 2+2?")
    }
);

var response = await client.CompleteAsync(request);
Console.WriteLine(response.Choices[0].Message.Content);

// Streaming chat completion
var streamRequest = request with { Stream = true };
await foreach (var chunk in client.StreamAsync(streamRequest))
{
    if (chunk.TextDelta != null)
        Console.Write(chunk.TextDelta);
}

// Embeddings
var embeddingRequest = new EmbeddingRequest(
    model: "openai/text-embedding-3-small",
    input: new List<string> { "text 1", "text 2" }
);
float[][] embeddings = await client.EmbedAsync(embeddingRequest.Model, embeddingRequest.Input);
// embeddings[0] is vector for "text 1"

SearxngClient

var searxng = new SearxngClient("http://localhost:8002");

List<SearxngResult> results = await searxng.SearchAsync("quantum physics", limit: 5);

foreach (var result in results)
{
    Console.WriteLine($"{result.Title}");
    Console.WriteLine($"{result.Url}");
    Console.WriteLine($"{result.Content}");
    Console.WriteLine();
}

EmbeddingService

var client = new OpenRouterClient("your-api-key");
var embeddingService = new EmbeddingService(client); // default model: openai/text-embedding-3-small

// Single embedding
float[] embedding = await embeddingService.GetEmbeddingAsync("Hello world");

// Batch embeddings (with progress)
List<string> texts = new() { "text 1", "text 2", "text 3" };
float[][] embeddings = await embeddingService.GetEmbeddingsAsync(
    texts,
    onProgress: msg => Console.WriteLine(msg)
);

// Cosine similarity
float similarity = EmbeddingService.CosineSimilarity(embedding1, embedding2);

ArticleService

var article = await ArticleService.FetchArticleAsync("https://example.com/article");
Console.WriteLine(article.Title);
Console.WriteLine(article.TextContent);
Console.WriteLine($"Readable: {article.IsReadable}");

Note: Article type comes from SmartReader library (not OpenQuery-specific).

ChunkingService

List<string> chunks = ChunkingService.ChunkText("Long article text...");

foreach (var chunk in chunks)
{
    Console.WriteLine($"Chunk ({chunk.Length} chars): {chunk.Substring(0, 50)}...");
}

SearchTool (Orchestration)

var searxngClient = new SearxngClient("http://localhost:8002");
var embeddingService = new EmbeddingService(openRouterClient);
var searchTool = new SearchTool(searxngClient, embeddingService);

string context = await searchTool.ExecuteAsync(
    originalQuery: "What is quantum entanglement?",
    generatedQueries: new List<string>
    {
        "quantum entanglement definition",
        "how quantum entanglement works"
    },
    maxResults: 5,
    topChunksLimit: 3,
    onProgress: msg => Console.WriteLine(msg),
    verbose: true
);

Console.WriteLine("Context:");
Console.WriteLine(context);

Output is a formatted string:

[Source 1: Title](https://example.com/1)
Content chunk...

[Source 2: Title](https://example.com/2)
Content chunk...

Custom Implementations

Custom Progress Reporter

SearchTool.ExecuteAsync accepts Action<string>? onProgress. Provide your own:

public class MyProgressReporter
{
    public void Report(string message)
    {
        // Log to file
        File.AppendAllText("log.txt", $"{DateTime.UtcNow}: {message}\n");
        
        // Update UI
        myLabel.Text = message;
        
        // Send to telemetry
        Telemetry.TrackEvent("OpenQueryProgress", new { message });
    }
}

// Usage
var reporter = new MyProgressReporter();
await searchTool.ExecuteAsync(..., reporter.Report, verbose: false);

Custom Chunking Strategy

Extend ChunkingService or implement your own:

public static class MyChunkingService
{
    public static List<string> ChunkText(string text, int maxSize = 500, int overlap = 50)
    {
        // Overlapping chunks for better context retrieval
        var chunks = new List<string>();
        int start = 0;
        while (start < text.Length)
        {
            int end = Math.Min(start + maxSize, text.Length);
            var chunk = text.Substring(start, end - start);
            chunks.Add(chunk);
            start += maxSize - overlap; // Slide window
        }
        return chunks;
    }
}

Custom Rate Limiter

Implement IAsyncDisposable with your own strategy (token bucket, leaky bucket):

public class TokenBucketRateLimiter : IAsyncDisposable
{
    private readonly SemaphoreSlim _semaphore;
    private readonly TimeSpan _refillPeriod;
    private int _tokens;
    private readonly int _maxTokens;
    
    // Implementation details...
    
    public async Task<T> ExecuteAsync<T>(Func<Task<T>> action, CancellationToken ct)
    {
        await WaitForTokenAsync(ct);
        try
        {
            return await action();
        }
        finally
        {
            // Return tokens or replenish bucket
        }
    }
}

Thread Safety

Thread-Safe Components:

  • RateLimiter - SemaphoreSlim is thread-safe
  • StatusReporter - Channel is thread-safe
  • Static utility classes (ChunkingService) - no state

Not Thread-Safe (instances should not be shared across threads):

  • OpenRouterClient - wraps HttpClient (which is thread-safe but instance may have state)
  • SearxngClient - HttpClient (thread-safe but reuse recommendations apply)
  • EmbeddingService - has mutable fields (_rateLimiter, _retryPipeline)
  • SearchTool - has mutable _options

Recommendation: Create new instances per operation or use locks if sharing.

Example: Parallel Queries

var tasks = questions.Select(async question =>
{
    var options = new OpenQueryOptions(..., question: question);
    var query = new OpenQueryApp(client, searchTool, model);
    await query.RunAsync(options);
    // Separate instances per task
});

await Task.WhenAll(tasks);

Better: Create factory that spawns fresh instances.


Error Handling

All public async methods may throw:

  • HttpRequestException - network errors, non-2xx responses
  • TaskCanceledException - timeout or cancellation
  • JsonException - malformed JSON
  • Argument*Exception - invalid arguments
  • Exception - any other error

Pattern: Try-Catch

try
{
    var response = await client.CompleteAsync(request);
    Console.WriteLine(response.Choices[0].Message.Content);
}
catch (HttpRequestException ex)
{
    Console.Error.WriteLine($"Network error: {ex.Message}");
}
catch (Exception ex)
{
    Console.Error.WriteLine($"Unexpected error: {ex.Message}");
}

Pattern: Resilience with Polly

EmbeddingService already wraps client.EmbedAsync with Polly retry. For other calls, you can add your own:

var retryPolicy = Policy
    .Handle<HttpRequestException>()
    .WaitAndRetryAsync(3, attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)));

await retryPolicy.ExecuteAsync(async () =>
{
    var response = await client.CompleteAsync(request);
    // ...
});

Advanced Usage

Streaming Responses to Network

var request = new ChatCompletionRequest(model, messages) { Stream = true };
var response = await client.StreamAsync(request);

await foreach (var chunk in response)
{
    if (chunk.TextDelta != null)
    {
        await networkStream.WriteAsync(Encoding.UTF8.GetBytes(chunk.TextDelta));
    }
}

Parallel Embedding Batches with Progress

var texts = Enumerable.Range(0, 1000).Select(i => $"Text {i}").ToList();

await embeddingService.GetEmbeddingsAsync(texts, 
    onProgress: progress => 
    {
        Console.WriteLine(progress); // "[Generating embeddings: batch 5/4]"
    });

Custom Embedding Service with Different Model

var client = new OpenRouterClient(apiKey);
var customService = new EmbeddingService(client, "your-embedding-model");

float[] embedding = await customService.GetEmbeddingAsync("text");

Limitations

No Interface-based Design

OpenQuery uses concrete classes. For mocking in tests, you'd need to create wrappers or use tools like JustMock/Moq that can mock non-virtual methods (not recommended). Better: define interfaces like IOpenRouterClient and have implementations.

Hardcoded Concurrency Settings

ParallelProcessingOptions is instantiated in SearchTool with hardcoded defaults. To customize, you'd need to:

  1. Subclass SearchTool and override access to _options
  2. Or modify source to accept ParallelProcessingOptions in constructor
  3. Or use reflection (hacky)

Suggested improvement: Add constructor parameter.

Single Responsibility Blur

OpenQueryApp does query generation + pipeline + streaming. Could split:

  • IQueryGenerator (for expanding queries)
  • IPipelineExecutor (for search tool)
  • IAnswerStreamer (for final LLM streaming)

Currently, OpenQueryApp is the facade.


Next Steps


Code Snippet: Full Programmatic Flow

using OpenQuery.Services;
using OpenQuery.Tools;
using OpenQuery.Models;

async Task<string> Research(string question)
{
    var apiKey = GetApiKey(); // your method
    var client = new OpenRouterClient(apiKey);
    var searxng = new SearxngClient("http://localhost:8002");
    var embeddings = new EmbeddingService(client);
    var search = new SearchTool(searxng, embeddings);
    var app = new OpenQueryApp(client, search, "qwen/qwen3.5-flash-02-23");
    
    var options = new OpenQueryOptions(
        Chunks: 3,
        Results: 5,
        Queries: 3,
        Short: false,
        Long: false,
        Verbose: false,
        Question: question
    );
    
    // Capture output by redirecting Console or modifying OpenQueryApp
    await app.RunAsync(options);
    return "streamed to console"; // would need custom capture
}