From bdb6ba77536b6242b8728e044415f4ca43921223 Mon Sep 17 00:00:00 2001 From: Justyna Wojtczak Date: Wed, 18 Mar 2026 00:21:17 +0100 Subject: [PATCH] fix: use Ollama OpenAI-compatible endpoint (/v1) by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Ollama provider inherits from OpenAI but used the native API base (e.g. http://localhost:11434) which doesn't serve endpoints like /chat/completions or /models. This caused: - Model listing to fail with 404 (tried /models instead of /api/tags) - with_schema to silently not work (models not in registry, no structured_output capability detected) Fix: automatically append /v1 to ollama_api_base so the provider uses Ollama's OpenAI-compatible endpoint. Idempotent — does not double-append if /v1 is already present. This gives: - Model listing works (/v1/models) - Chat works (/v1/chat/completions) - with_schema works (response_format passed correctly) - All OpenAI provider logic inherited without changes Tested with Ollama 0.17.5, gemma:latest (9B), llama3.2:3b (3B). Note: Ollama's native /api/chat endpoint with `format` param provides better nested JSON Schema enforcement than the OpenAI- compatible endpoint. A future enhancement could use the native endpoint for structured output. --- lib/ruby_llm/providers/ollama.rb | 8 ++- spec/ruby_llm/providers/ollama_spec.rb | 79 +++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/lib/ruby_llm/providers/ollama.rb b/lib/ruby_llm/providers/ollama.rb index 3c0d676a2..45c6e2fd7 100644 --- a/lib/ruby_llm/providers/ollama.rb +++ b/lib/ruby_llm/providers/ollama.rb @@ -8,8 +8,14 @@ class Ollama < OpenAI include Ollama::Media include Ollama::Models + # Ollama exposes two API surfaces: + # - Native API at /api/* (different request/response format) + # - OpenAI-compatible API at /v1/* (same format as OpenAI) + # Since this provider inherits from OpenAI, we use the /v1 endpoint + # so all OpenAI logic (chat, models, schemas) works without changes. def api_base - @config.ollama_api_base + base = @config.ollama_api_base.to_s.chomp('/') + base.end_with?('/v1') ? base : "#{base}/v1" end def headers diff --git a/spec/ruby_llm/providers/ollama_spec.rb b/spec/ruby_llm/providers/ollama_spec.rb index 9910e08c1..eae77e17a 100644 --- a/spec/ruby_llm/providers/ollama_spec.rb +++ b/spec/ruby_llm/providers/ollama_spec.rb @@ -3,21 +3,82 @@ require 'spec_helper' RSpec.describe RubyLLM::Providers::Ollama do - include_context 'with configured RubyLLM' + subject(:provider) { described_class.new(config) } + + let(:config) do + instance_double( + RubyLLM::Configuration, + request_timeout: 300, + max_retries: 3, + retry_interval: 0.1, + retry_interval_randomness: 0.5, + retry_backoff_factor: 2, + http_proxy: nil, + ollama_api_base: ollama_api_base, + ollama_api_key: ollama_api_key + ) + end + + let(:ollama_api_base) { 'http://localhost:11434' } + let(:ollama_api_key) { nil } describe '#headers' do - it 'returns empty headers when no API key is configured' do - RubyLLM.configure { |config| config.ollama_api_key = nil } - provider = described_class.new(RubyLLM.config) + context 'when no API key is configured' do + let(:ollama_api_key) { nil } + + it 'returns empty headers' do + expect(provider.headers).to eq({}) + end + end + + context 'when API key is configured' do + let(:ollama_api_key) { 'test-ollama-key' } + + it 'returns Authorization header' do + expect(provider.headers).to eq({ 'Authorization' => 'Bearer test-ollama-key' }) + end + end + end + + describe '#api_base' do + context 'when base URL does not include /v1' do + let(:ollama_api_base) { 'http://localhost:11434' } + + it 'appends /v1 for OpenAI-compatible endpoint' do + expect(provider.api_base).to eq('http://localhost:11434/v1') + end + end + + context 'when base URL already includes /v1' do + let(:ollama_api_base) { 'http://localhost:11434/v1' } + + it 'does not double-append /v1' do + expect(provider.api_base).to eq('http://localhost:11434/v1') + end + end + + context 'when base URL has a trailing slash' do + let(:ollama_api_base) { 'http://localhost:11434/' } + + it 'strips trailing slash and appends /v1' do + expect(provider.api_base).to eq('http://localhost:11434/v1') + end + end + + context 'when base URL has /v1/ with trailing slash' do + let(:ollama_api_base) { 'http://localhost:11434/v1/' } - expect(provider.headers).to eq({}) + it 'normalizes to /v1 without trailing slash' do + expect(provider.api_base).to eq('http://localhost:11434/v1') + end end - it 'returns Authorization header when API key is configured' do - RubyLLM.configure { |config| config.ollama_api_key = 'test-ollama-key' } - provider = described_class.new(RubyLLM.config) + context 'when using a custom host and port' do + let(:ollama_api_base) { 'https://my-ollama.com:8080' } - expect(provider.headers).to eq({ 'Authorization' => 'Bearer test-ollama-key' }) + it 'appends /v1 to the custom base' do + expect(provider.api_base).to eq('https://my-ollama.com:8080/v1') + end end end end