Streams POST {baseUrl}/chat/completions over SSE (System.Net.ServerSentEvents, no NuGet) and maps delta.content, delta.reasoning_content / delta.reasoning and delta.tool_calls onto AssistantMessageEvents via AssistantMessageBuilder. Configurable through OpenAIProviderOptions so one extension serves OpenAI, OpenRouter, Ollama and other compatible servers; ships the gpt-5 / gpt-5-mini / gpt-4.1 catalogue.
87 lines
4.7 KiB
C#
87 lines
4.7 KiB
C#
using Volcanic.AgenticCore.Abstractions.Providers;
|
|
|
|
namespace Volcanic.AgenticCore.Extensions.OpenAI;
|
|
|
|
/// <summary>
|
|
/// Configures one OpenAI-compatible chat completions endpoint. The defaults describe OpenAI itself;
|
|
/// use <see cref="OpenRouter"/>, <see cref="Ollama"/> or set the properties by hand for other servers
|
|
/// (Groq, llama.cpp, vLLM, LM Studio, ...).
|
|
/// </summary>
|
|
public sealed record OpenAIProviderOptions
|
|
{
|
|
/// <summary>Provider id used in model keys (<c>provider/model</c>) and API key lookups.</summary>
|
|
public string ProviderId { get; init; } = OpenAIModels.ProviderId;
|
|
|
|
/// <summary>Display name. <c>null</c> falls back to "OpenAI" for the <c>openai</c> provider, otherwise to <see cref="ProviderId"/>.</summary>
|
|
public string? Name { get; init; }
|
|
|
|
/// <summary>Base URL up to and including the API version segment; <c>/chat/completions</c> is appended.</summary>
|
|
public string BaseUrl { get; init; } = OpenAIModels.DefaultBaseUrl;
|
|
|
|
/// <summary>
|
|
/// API key spec (<c>literal</c>, <c>$ENV_VAR</c> or <c>!command</c>). <c>null</c> means <c>$OPENAI_API_KEY</c> for the
|
|
/// <c>openai</c> provider; for any other provider it stays <c>null</c> so the registry falls back to <c>auth.json</c> and <c><PROVIDERID>_API_KEY</c>.
|
|
/// </summary>
|
|
public string? ApiKeySpec { get; init; }
|
|
|
|
/// <summary>Models to register. <c>null</c> means <see cref="OpenAIModels.All"/> for the <c>openai</c> provider, otherwise none.</summary>
|
|
public IReadOnlyList<Model>? Models { get; init; }
|
|
|
|
/// <summary>Extra headers sent with every request (OpenRouter attribution, proxy auth, ...).</summary>
|
|
public IReadOnlyDictionary<string, string>? Headers { get; init; }
|
|
|
|
/// <summary>False for local servers that accept unauthenticated requests.</summary>
|
|
public bool RequiresApiKey { get; init; } = true;
|
|
|
|
/// <summary>When true the system prompt is sent with role <c>developer</c> instead of <c>system</c>.</summary>
|
|
public bool SupportsDeveloperRole { get; init; }
|
|
|
|
/// <summary>Name of the output-limit field: OpenAI wants <c>max_completion_tokens</c>; many other servers only know <c>max_tokens</c>.</summary>
|
|
public string MaxTokensField { get; init; } = "max_completion_tokens";
|
|
|
|
/// <summary>Whether to send <c>reasoning_effort</c> for reasoning models. Turn off for servers that reject unknown fields.</summary>
|
|
public bool SendReasoningEffort { get; init; } = true;
|
|
|
|
/// <summary>True when this configuration targets the <c>openai</c> provider id (case-insensitive).</summary>
|
|
public bool IsOpenAI => string.Equals(ProviderId, OpenAIModels.ProviderId, StringComparison.OrdinalIgnoreCase);
|
|
|
|
/// <summary><see cref="ApiKeySpec"/> with the <c>openai</c> default applied.</summary>
|
|
public string? EffectiveApiKeySpec => ApiKeySpec ?? (IsOpenAI ? "$OPENAI_API_KEY" : null);
|
|
|
|
/// <summary><see cref="Models"/> with the <c>openai</c> default applied.</summary>
|
|
public IReadOnlyList<Model> EffectiveModels => Models ?? (IsOpenAI ? OpenAIModels.All : []);
|
|
|
|
/// <summary><see cref="Name"/> with the defaults applied.</summary>
|
|
public string EffectiveName => Name ?? (IsOpenAI ? "OpenAI" : ProviderId);
|
|
|
|
/// <summary>OpenRouter (<c>https://openrouter.ai/api/v1</c>), provider id <c>openrouter</c>, key from <c>OPENROUTER_API_KEY</c>.</summary>
|
|
/// <param name="models">Models to expose; OpenRouter ids look like <c>anthropic/claude-sonnet-4</c>.</param>
|
|
/// <param name="httpReferer">Optional <c>HTTP-Referer</c> attribution header.</param>
|
|
/// <param name="title">Optional <c>X-Title</c> attribution header.</param>
|
|
public static OpenAIProviderOptions OpenRouter(IReadOnlyList<Model> models, string? httpReferer = null, string? title = null)
|
|
{
|
|
Dictionary<string, string>? headers = null;
|
|
if (!string.IsNullOrEmpty(httpReferer)) (headers ??= new(StringComparer.OrdinalIgnoreCase))["HTTP-Referer"] = httpReferer;
|
|
if (!string.IsNullOrEmpty(title)) (headers ??= new(StringComparer.OrdinalIgnoreCase))["X-Title"] = title;
|
|
return new OpenAIProviderOptions
|
|
{
|
|
ProviderId = "openrouter",
|
|
Name = "OpenRouter",
|
|
BaseUrl = "https://openrouter.ai/api/v1",
|
|
Models = models,
|
|
Headers = headers,
|
|
};
|
|
}
|
|
|
|
/// <summary>A local Ollama server: no API key, <c>max_tokens</c> instead of <c>max_completion_tokens</c>.</summary>
|
|
public static OpenAIProviderOptions Ollama(IReadOnlyList<Model> models, string baseUrl = "http://localhost:11434/v1") => new()
|
|
{
|
|
ProviderId = "ollama",
|
|
Name = "Ollama",
|
|
BaseUrl = baseUrl,
|
|
Models = models,
|
|
RequiresApiKey = false,
|
|
MaxTokensField = "max_tokens",
|
|
};
|
|
}
|