ruby_llm-providers-apfel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 691f6aca0f671994fe947ed562e4b35720453513152f8b5d959023266679bc97
4
+ data.tar.gz: 87e2c3d286eb1e31575cfc661a66f95015a36fc5f5a39ed02404e82925a86043
5
+ SHA512:
6
+ metadata.gz: 6db0f5d2633e80c9897733b133b77fdd472dfad063449a4b10361788ae2b7192649f8a440922a926c1404025b7654301c2291bbb8c9124fbd352d0831c742d3c
7
+ data.tar.gz: 3178067325ab285c5b62e8bd1e78ed32c65990b9fbd509ed4b8a0cabcf06dedebe83f4db8b21850da37b71a43498dd3d69265ceb30a100cd498c6e77c1e7a835
data/.flayignore ADDED
@@ -0,0 +1 @@
1
+ spec/support/streaming_error_helpers.rb
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ branches: ["main"]
6
+ push:
7
+ branches: ["main"]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ test:
14
+ name: Ruby ${{ matrix.ruby-version }}
15
+ runs-on: ubuntu-latest
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ ruby-version: ["3.1", "3.2", "3.3", "3.4", "4.0"]
20
+ steps:
21
+ - uses: actions/checkout@v6
22
+
23
+ - uses: ruby/setup-ruby@v1
24
+ with:
25
+ ruby-version: ${{ matrix.ruby-version }}
26
+ bundler-cache: true
27
+
28
+ - run: bundle exec rake
29
+ if: matrix.ruby-version != '3.1'
30
+
31
+ - run: bundle exec rake rubocop flay spec
32
+ if: matrix.ruby-version == '3.1'
@@ -0,0 +1,22 @@
1
+ name: Gitleaks
2
+
3
+ on:
4
+ pull_request:
5
+ branches: ["main"]
6
+ push:
7
+ branches: ["main"]
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ scan:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v6
17
+ with:
18
+ fetch-depth: 0
19
+
20
+ - uses: gitleaks/gitleaks-action@v2
21
+ env:
22
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,36 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ publish:
13
+ name: Build and publish
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v6
17
+
18
+ - uses: ruby/setup-ruby@v1
19
+ with:
20
+ ruby-version: "3.4"
21
+ bundler-cache: true
22
+
23
+ - run: bundle exec rake
24
+
25
+ - name: Build gem
26
+ run: gem build ruby_llm-providers-apfel.gemspec
27
+
28
+ - name: Publish to RubyGems
29
+ run: |
30
+ mkdir -p "$HOME/.gem"
31
+ touch "$HOME/.gem/credentials"
32
+ chmod 0600 "$HOME/.gem/credentials"
33
+ printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > "$HOME/.gem/credentials"
34
+ gem push ruby_llm-providers-apfel-*.gem
35
+ env:
36
+ GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_AUTH_TOKEN }}
data/.overcommit.yml ADDED
@@ -0,0 +1,31 @@
1
+ PreCommit:
2
+ RuboCop:
3
+ enabled: true
4
+ auto_correct: true
5
+ on_warn: fail
6
+
7
+ Flay:
8
+ enabled: true
9
+ include:
10
+ - lib/**/*.rb
11
+ - spec/**/*.rb
12
+ mass_threshold: 70
13
+
14
+ ArchSpec:
15
+ enabled: true
16
+ command: ['bundle', 'exec', 'archspec', 'check']
17
+ on_warn: fail
18
+
19
+ RSpec:
20
+ enabled: true
21
+ command: ['bundle', 'exec', 'rspec']
22
+ on_warn: fail
23
+
24
+ TrailingWhitespace:
25
+ enabled: true
26
+ auto_correct: true
27
+
28
+ Gitleaks:
29
+ enabled: true
30
+ required_executable: gitleaks
31
+ command: ['gitleaks', 'protect', '--staged', '--redact', '--verbose']
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --require spec_helper
2
+ --format progress
data/.rubocop.yml ADDED
@@ -0,0 +1,29 @@
1
+ plugins:
2
+ - rubocop-performance
3
+ - rubocop-rake
4
+ - rubocop-rspec
5
+
6
+ AllCops:
7
+ NewCops: enable
8
+ TargetRubyVersion: 3.1
9
+ Exclude:
10
+ - Archspec.rb
11
+ - vendor/**/*
12
+ - gemfiles/**/*
13
+ SuggestExtensions: false
14
+
15
+ Metrics/AbcSize:
16
+ Max: 80
17
+ Metrics/MethodLength:
18
+ Max: 40
19
+ CountAsOne:
20
+ - array
21
+ - hash
22
+ - heredoc
23
+ Metrics/BlockLength:
24
+ Exclude:
25
+ - spec/**/*.rb
26
+ RSpec/ExampleLength:
27
+ Enabled: false
28
+ RSpec/MultipleExpectations:
29
+ Enabled: false
data/Archspec.rb ADDED
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'lib/**/*.rb'
4
+
5
+ component :provider,
6
+ in: %w[
7
+ lib/ruby_llm/providers/apfel.rb
8
+ lib/ruby_llm/providers/apfel/**/*.rb
9
+ ],
10
+ namespace: 'RubyLLM::Providers::Apfel'
11
+
12
+ provider.cannot_reference_constants 'RSpec', 'WebMock', 'VCR'
13
+
14
+ preset :ruby_conventions
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 your-github-org
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.
data/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # ruby_llm-providers-apfel
2
+
3
+ RubyLLM provider gem for [Apfel](https://apfel.franzai.com/), a CLI tool that exposes Apple's
4
+ on-device Foundation Models framework through an OpenAI-compatible HTTP server. With Apfel and
5
+ this gem, RubyLLM can talk to Apple's on-device model (AFM) as a fully local, offline LLM
6
+ provider — no API key, no cloud calls, no per-token billing.
7
+
8
+ ## Requirements
9
+
10
+ - Apple Silicon Mac (M-series)
11
+ - macOS 26 (Tahoe) or later
12
+ - Apple Intelligence enabled
13
+
14
+ ## Installing Apfel
15
+
16
+ Apfel itself is a separate Homebrew package, not a Ruby gem. Install it once per machine:
17
+
18
+ ```sh
19
+ brew install apfel
20
+ ```
21
+
22
+ Start it as a local OpenAI-compatible server:
23
+
24
+ ```sh
25
+ apfel --serve
26
+ ```
27
+
28
+ By default the server listens at `http://127.0.0.1:11434/v1`. Authentication is optional — pass
29
+ `--token <value>` to `apfel --serve` if you want to require a Bearer token; otherwise no API key
30
+ is needed at all.
31
+
32
+ Apfel serves Apple's on-device Foundation Model (AFM, ~3B parameters) running entirely on the
33
+ Neural Engine. It has a combined input/output context window of 4,096 tokens, so keep prompts and
34
+ expected responses within that budget.
35
+
36
+ ## Installing the gem
37
+
38
+ Add this line to your application's Gemfile:
39
+
40
+ ```ruby
41
+ gem 'ruby_llm-providers-apfel', require: 'ruby_llm/providers/apfel'
42
+ ```
43
+
44
+ Then configure the provider to point at your local Apfel server:
45
+
46
+ ```ruby
47
+ require 'ruby_llm/providers/apfel'
48
+
49
+ RubyLLM.configure do |config|
50
+ config.apfel_api_base = ENV.fetch('APFEL_API_BASE', 'http://127.0.0.1:11434/v1')
51
+ # Only needed if you started `apfel --serve --token <value>`
52
+ config.apfel_api_key = ENV['APFEL_API_KEY']
53
+ end
54
+ ```
55
+
56
+ Since Apfel runs locally with no API key required, `apfel_api_key` can be left unset unless you
57
+ started the server with `--token`.
58
+
59
+ ## Usage
60
+
61
+ ```ruby
62
+ model = RubyLLM.models.by_provider(:apfel).chat_models.first
63
+ chat = RubyLLM.chat(model: model.id)
64
+ response = chat.ask('Hello')
65
+ puts response.content
66
+ ```
67
+
68
+ ## Development
69
+
70
+ The generator installs the bundle and creates an ignored `.env`. Edit the generated `op read` reference so it points to your 1Password credential. If you do not use the 1Password CLI, replace the expression with the provider key.
71
+
72
+ ```sh
73
+ bundle exec rake models
74
+ bundle exec rake
75
+ ```
76
+
77
+ `rake models` calls only Apfel's model-listing endpoint and writes `models.json` at the gem root. RubyLLM loads that catalog as a fallback when this provider is registered, so applications do not need to combine registry files. The main RubyLLM registry always wins when both catalogs carry the same model. If the API uses a different path, change `models_url` in `lib/ruby_llm/providers/apfel.rb`.
78
+
79
+ Run `rake models` from this provider gem when you want to update its packaged catalog. `RubyLLM.models.refresh` updates the application's main registry and does not refresh provider gem catalogs.
80
+
81
+ If the provider has no model-listing endpoint, uncomment `assume_models_exist?` in the provider and do not run `rake models`.
82
+
83
+ The suite always runs the provider integration specs. The first local run calls the API and records VCR cassettes; CI only replays committed cassettes. A failing example deletes its cassette so the next local run tests the live API again.
84
+
85
+ After refreshing the catalog, add real model IDs to `spec/support/models.rb`. Keep only the operation matrices the provider supports. The portable contract specs are adapted from [RubyLLM's live specs](https://github.com/crmne/ruby_llm/tree/main/spec/ruby_llm); use those as the reference when your provider needs coverage for another feature or dialect.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+
5
+ module RubyLLM
6
+ module Providers
7
+ class Apfel < Provider
8
+ # Wraps the transport connection so a request against a server that
9
+ # is not running surfaces as a RubyLLM::Error explaining how to start
10
+ # Apfel, instead of a raw Faraday::ConnectionFailed.
11
+ class ConnectionGuard < SimpleDelegator
12
+ %i[get post patch delete].each do |verb|
13
+ define_method(verb) do |*args, **kwargs, &block|
14
+ __getobj__.public_send(verb, *args, **kwargs, &block)
15
+ rescue Faraday::ConnectionFailed
16
+ raise Error, unreachable_message
17
+ end
18
+ end
19
+
20
+ def unreachable_message
21
+ "Could not connect to the Apfel server at #{provider.api_base}. " \
22
+ 'Install it with `brew install apfel` if needed, then start it with `apfel --serve`, ' \
23
+ 'and try again.'
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Providers
5
+ # Version of the ruby_llm-providers-apfel gem. Kept in its own file so the
6
+ # gemspec can read the literal without loading the provider.
7
+ class Apfel < Provider
8
+ VERSION = '0.1.0'
9
+
10
+ def self.version
11
+ VERSION
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+ require 'ruby_llm/providers/apfel/version'
5
+ require 'ruby_llm/providers/apfel/connection_guard'
6
+
7
+ module RubyLLM
8
+ module Providers
9
+ # Apfel API integration.
10
+ class Apfel < Provider
11
+ # Apfel's ChatCompletions protocol.
12
+ class ChatCompletions < Protocols::ChatCompletions
13
+ def models_url
14
+ 'models'
15
+ end
16
+ end
17
+
18
+ protocol :chat_completions, ChatCompletions
19
+
20
+ def initialize(config)
21
+ super
22
+ @connection = ConnectionGuard.new(@connection)
23
+ end
24
+
25
+ def api_base
26
+ @config.apfel_api_base || 'https://api.example.com/v1'
27
+ end
28
+
29
+ def headers
30
+ { 'Authorization' => "Bearer #{@config.apfel_api_key}" }
31
+ end
32
+
33
+ class << self
34
+ def configuration_options
35
+ %i[apfel_api_key apfel_api_base]
36
+ end
37
+
38
+ def configuration_requirements
39
+ %i[apfel_api_key]
40
+ end
41
+
42
+ # Use this only when the provider has no model-listing endpoint.
43
+ # def assume_models_exist?
44
+ # true
45
+ # end
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ RubyLLM::Provider.register :apfel, RubyLLM::Providers::Apfel,
52
+ models: File.expand_path('../../../models.json', __dir__)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyLLM::Chat, :live do
6
+ include_context 'with configured RubyLLM'
7
+
8
+ let(:person_schema) do
9
+ {
10
+ type: 'object',
11
+ properties: {
12
+ name: { type: 'string' },
13
+ age: { type: 'integer' }
14
+ },
15
+ required: %w[name age],
16
+ additionalProperties: false
17
+ }
18
+ end
19
+
20
+ each_model(STRUCTURED_OUTPUT_MODELS) do |provider, model|
21
+ it "#{provider}/#{model} returns structured output" do
22
+ response = RubyLLM.chat(model: model, provider: provider, assume_model_exists: true)
23
+ .with_schema(person_schema)
24
+ .ask('Generate a person named John who is 30 years old')
25
+
26
+ expect(response.content).to be_a(String)
27
+ expect(response.parsed).to include('name' => 'John', 'age' => 30)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyLLM::Chat, :live do
6
+ include_context 'with configured RubyLLM'
7
+
8
+ each_model(CHAT_MODELS) do |provider, model|
9
+ it "#{provider}/#{model} can have a basic conversation" do
10
+ response = RubyLLM.chat(model: model, provider: provider, assume_model_exists: true).ask("What's 2 + 2?")
11
+
12
+ expect(response.content).to include('4')
13
+ expect(response.role).to eq(:assistant)
14
+ expect(response.tokens.input.to_i).to be_positive
15
+ expect(response.tokens.output.to_i).to be_positive
16
+ end
17
+
18
+ it "#{provider}/#{model} returns the raw response" do
19
+ response = RubyLLM.chat(model: model, provider: provider, assume_model_exists: true)
20
+ .ask('What is the capital of France?')
21
+
22
+ expect(response.raw.status).to eq(200)
23
+ expect(response.raw.headers).not_to be_empty
24
+ expect(response.raw.body).not_to be_empty
25
+ expect(response.raw.env.request_body).not_to be_empty
26
+ end
27
+
28
+ it "#{provider}/#{model} can handle a multi-turn conversation" do
29
+ chat = RubyLLM.chat(model: model, provider: provider, assume_model_exists: true)
30
+
31
+ expect(chat.ask('Who created the programming language Ruby?').content).to match(/Matz|Matsumoto/i)
32
+ expect(chat.ask('What year was Ruby first released?').content).to include('199')
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyLLM::Chat, :live do
6
+ include_context 'with configured RubyLLM'
7
+
8
+ each_model(CHAT_MODELS) do |provider, model|
9
+ it "#{provider}/#{model} supports streaming responses" do
10
+ chunks = []
11
+ chat = RubyLLM.chat(model: model, provider: provider, assume_model_exists: true)
12
+
13
+ response = chat.ask('Count from 1 to 3') { |chunk| chunks << chunk }
14
+
15
+ expect(chunks).not_to be_empty
16
+ expect(chunks.first).to be_a(RubyLLM::Chunk)
17
+ expect(response.raw.status).to eq(200)
18
+ expect(response.raw.headers).not_to be_empty
19
+ expect(response.raw.env.request_body).not_to be_empty
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyLLM::Chat, :live do
6
+ include_context 'with configured RubyLLM'
7
+
8
+ let(:weather_tool) do
9
+ Class.new(RubyLLM::Tool) do
10
+ description 'Gets current weather for a location'
11
+ parameter :latitude, description: 'Latitude'
12
+ parameter :longitude, description: 'Longitude'
13
+
14
+ def execute(latitude:, longitude:)
15
+ "Current weather at #{latitude}, #{longitude}: 15°C, Wind: 10 km/h"
16
+ end
17
+ end
18
+ end
19
+
20
+ each_model(TOOL_MODELS) do |provider, model|
21
+ it "#{provider}/#{model} can use tools" do
22
+ chat = RubyLLM.chat(model: model, provider: provider, assume_model_exists: true).with_tools(weather_tool)
23
+ response = chat.ask("What's the weather in Berlin? Use 52.5200, 13.4050.")
24
+
25
+ expect(response.content).to include('15')
26
+ expect(response.content).to include('10')
27
+ expect(chat.messages.any?(&:tool_call?)).to be(true)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyLLM::Embedding, :live do
6
+ include_context 'with configured RubyLLM'
7
+
8
+ each_model(EMBEDDING_MODELS) do |provider, model, model_info|
9
+ it "#{provider}/#{model} embeds one text" do
10
+ embedding = RubyLLM.embed(
11
+ "Ruby is a programmer's best friend",
12
+ model: model,
13
+ provider: provider,
14
+ assume_model_exists: true
15
+ )
16
+
17
+ expect(embedding.vectors).to be_an(Array)
18
+ expect(embedding.vectors.first).to be_a(Numeric)
19
+ expect(embedding.model).to eq(model)
20
+ expect(embedding.tokens.input.to_i).to be >= 0
21
+ end
22
+
23
+ it "#{provider}/#{model} embeds several texts" do
24
+ embeddings = RubyLLM.embed(
25
+ %w[Ruby Python JavaScript],
26
+ model: model,
27
+ provider: provider,
28
+ assume_model_exists: true
29
+ )
30
+
31
+ expect(embeddings.vectors.size).to eq(3)
32
+ expect(embeddings.vectors).to all(be_an(Array))
33
+ end
34
+
35
+ next unless model_info[:dimensions]
36
+
37
+ it "#{provider}/#{model} supports custom dimensions" do
38
+ embedding = RubyLLM.embed(
39
+ 'Ruby',
40
+ model: model,
41
+ provider: provider,
42
+ assume_model_exists: true,
43
+ dimensions: model_info[:dimensions]
44
+ )
45
+
46
+ expect(embedding.vectors.length).to eq(model_info[:dimensions])
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyLLM::Image, :live do
6
+ include_context 'with configured RubyLLM'
7
+
8
+ each_model(IMAGE_GENERATION_MODELS) do |provider, model, model_info|
9
+ it "#{provider}/#{model} paints an image" do
10
+ image = RubyLLM.paint(
11
+ 'a siamese cat',
12
+ model: model,
13
+ provider: provider,
14
+ assume_model_exists: true,
15
+ provider_options: model_info.fetch(:provider_options, {})
16
+ )
17
+
18
+ expect(image.mime_type).to include('image')
19
+ expect(image.model).to eq(model)
20
+ expect(image.to_blob.bytesize).to be > 1000
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyLLM::Models do
6
+ include_context 'with configured RubyLLM'
7
+
8
+ it 'expects generated providers to add registry metadata before use' do
9
+ expect(RubyLLM::Providers::Apfel.assume_models_exist?).to be(false)
10
+ end
11
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyLLM::Moderation, :live do
6
+ include_context 'with configured RubyLLM'
7
+
8
+ each_model(MODERATION_MODELS) do |provider, model|
9
+ it "#{provider}/#{model} moderates content" do
10
+ moderation = RubyLLM.moderate(
11
+ 'This is a safe message',
12
+ model: model,
13
+ provider: provider,
14
+ assume_model_exists: true
15
+ )
16
+
17
+ expect(moderation).to be_a(described_class)
18
+ expect(moderation.results).to all(be_a(RubyLLM::Moderation::Result))
19
+ expect(moderation.flagged?).to be_in([true, false])
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyLLM::Providers::Apfel do
6
+ subject(:provider) { described_class.new(config) }
7
+
8
+ let(:config) do
9
+ RubyLLM::Configuration.new.tap do |provider_config|
10
+ provider_config.apfel_api_key = 'test-key'
11
+ provider_config.apfel_api_base = 'https://example.test/v1'
12
+ end
13
+ end
14
+
15
+ it 'is registered with RubyLLM' do
16
+ expect(RubyLLM::Provider.resolve(:apfel)).to eq(described_class)
17
+ end
18
+
19
+ it 'registers a default protocol' do
20
+ expect(described_class.protocols).to include(chat_completions: described_class::ChatCompletions)
21
+ end
22
+
23
+ it 'declares provider configuration' do
24
+ expect(described_class.configuration_options).to eq(%i[apfel_api_key apfel_api_base])
25
+ expect(described_class.configuration_requirements).to eq(%i[apfel_api_key])
26
+ end
27
+
28
+ it 'uses configured API base and bearer token' do
29
+ expect(provider.api_base).to eq('https://example.test/v1')
30
+ expect(provider.headers).to eq('Authorization' => 'Bearer test-key')
31
+ end
32
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyLLM::Rerank, :live do
6
+ include_context 'with configured RubyLLM'
7
+
8
+ each_model(RERANK_MODELS) do |provider, model|
9
+ it "#{provider}/#{model} orders documents by relevance" do
10
+ rerank = RubyLLM.rerank(
11
+ 'What is the capital of the United States?',
12
+ ['Carson City is the capital of Nevada.', 'Washington, D.C. is the capital of the United States.'],
13
+ model: model,
14
+ provider: provider,
15
+ assume_model_exists: true
16
+ )
17
+
18
+ expect(rerank.results.first.document).to include('Washington')
19
+ expect(rerank.results.first.score).to be > rerank.results.last.score
20
+ expect(rerank.results.map(&:index)).to contain_exactly(0, 1)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyLLM::Speech, :live do
6
+ include_context 'with configured RubyLLM'
7
+
8
+ each_model(SPEECH_MODELS) do |provider, model, model_info|
9
+ it "#{provider}/#{model} speaks" do
10
+ speech = RubyLLM.speak(
11
+ 'Ruby is a programming language designed for developer happiness.',
12
+ model: model,
13
+ provider: provider,
14
+ assume_model_exists: true,
15
+ voice: model_info[:voice],
16
+ format: model_info[:format]
17
+ )
18
+
19
+ expect(speech.data).to be_a(String)
20
+ expect(speech.data.bytesize).to be > 1000
21
+ expect(speech.model).to eq(model)
22
+ expect(speech.mime_type).to start_with('audio/')
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe RubyLLM::Video, :live do
6
+ include_context 'with configured RubyLLM'
7
+
8
+ before do
9
+ RubyLLM.config.video_generation_poll_interval = VCR.current_cassette&.recording? ? 5 : 0
10
+ end
11
+
12
+ each_model(VIDEO_GENERATION_MODELS) do |provider, model, model_info|
13
+ it "#{provider}/#{model} animates a video" do
14
+ video = RubyLLM.animate(
15
+ 'a calm ocean wave at sunset',
16
+ model: model,
17
+ provider: provider,
18
+ assume_model_exists: true,
19
+ provider_options: model_info.fetch(:provider_options, {})
20
+ )
21
+
22
+ expect(video.mime_type).to include('video')
23
+ expect(video.url || video.data).not_to be_nil
24
+ expect(video.to_blob.bytesize).to be > 10_000
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'dotenv/load'
5
+ require 'fileutils'
6
+
7
+ require 'vcr'
8
+ require 'ruby_llm/providers/apfel'
9
+ require 'webmock/rspec'
10
+
11
+ Dir[File.expand_path('support/**/*.rb', __dir__)].each { |file| require file }
12
+
13
+ RSpec.configure do |config|
14
+ config.disable_monkey_patching!
15
+ config.expect_with(:rspec) { |expectations| expectations.syntax = :expect }
16
+ config.order = :random
17
+ Kernel.srand config.seed
18
+
19
+ config.around(:each, :live) do |example|
20
+ cassette_name = example.full_description.downcase.gsub(/[^a-z0-9]+/, '_').delete_prefix('_').delete_suffix('_')
21
+ cassette_path = File.join(VCR.configuration.cassette_library_dir, "#{cassette_name}.yml")
22
+
23
+ VCR.use_cassette(cassette_name) { example.run }
24
+ FileUtils.rm_f(cassette_path) if example.exception
25
+ end
26
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Remove chat-derived matrices the model does not support and add real ids for the empty operation matrices.
4
+ # See RubyLLM's full live matrix and specs: https://github.com/crmne/ruby_llm/tree/main/spec
5
+ PROVIDER = :apfel
6
+ CHAT_MODELS = [].freeze
7
+ TOOL_MODELS = CHAT_MODELS
8
+ STRUCTURED_OUTPUT_MODELS = CHAT_MODELS
9
+
10
+ EMBEDDING_MODELS = [].freeze
11
+ IMAGE_GENERATION_MODELS = [].freeze
12
+ SPEECH_MODELS = [].freeze
13
+ VIDEO_GENERATION_MODELS = [].freeze
14
+ MODERATION_MODELS = [].freeze
15
+ RERANK_MODELS = [].freeze
16
+
17
+ def each_model(models)
18
+ models.each { |model_info| yield model_info[:provider], model_info[:model], model_info }
19
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_context 'with configured RubyLLM' do
4
+ before do
5
+ RubyLLM.configure do |config|
6
+ config.apfel_api_key = ENV.fetch('APFEL_API_KEY', 'test')
7
+ config.apfel_api_base = ENV.fetch('APFEL_API_BASE', 'https://api.example.com/v1')
8
+ config.max_retries = 0
9
+ config.retry_backoff_factor = 0
10
+ config.retry_interval = 0
11
+ config.retry_interval_randomness = 0
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ VCR.configure do |config|
4
+ config.cassette_library_dir = 'spec/fixtures/vcr_cassettes'
5
+ config.hook_into :webmock
6
+ config.default_cassette_options = { record: ENV['CI'] ? :none : :once }
7
+ config.allow_http_connections_when_no_cassette = true
8
+ config.filter_sensitive_data('<APFEL_API_KEY>') { ENV.fetch('APFEL_API_KEY', nil) }
9
+ config.filter_sensitive_data('<APFEL_API_BASE>') { ENV.fetch('APFEL_API_BASE', nil) }
10
+
11
+ config.before_record do |interaction|
12
+ next unless interaction.request.headers['Authorization']
13
+
14
+ interaction.request.headers['Authorization'] = ['Bearer <AUTH_TOKEN>']
15
+ end
16
+ end
metadata ADDED
@@ -0,0 +1,90 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_llm-providers-apfel
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - madbomber
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ruby_llm
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 2.0.0.rc1
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 2.0.0.rc1
26
+ description: Adds Apfel provider support to RubyLLM. Apfel is a macOS-only local server
27
+ that exposes Apple's on-device Apple Intelligence model over an OpenAI-compatible
28
+ API, letting RubyLLM talk to it like any other provider.
29
+ email:
30
+ - dvanhoozer@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - ".flayignore"
36
+ - ".github/workflows/ci.yml"
37
+ - ".github/workflows/gitleaks.yml"
38
+ - ".github/workflows/release.yml"
39
+ - ".overcommit.yml"
40
+ - ".rspec"
41
+ - ".rubocop.yml"
42
+ - Archspec.rb
43
+ - LICENSE
44
+ - README.md
45
+ - lib/ruby_llm/providers/apfel.rb
46
+ - lib/ruby_llm/providers/apfel/connection_guard.rb
47
+ - lib/ruby_llm/providers/apfel/version.rb
48
+ - spec/ruby_llm/chat_schema_spec.rb
49
+ - spec/ruby_llm/chat_spec.rb
50
+ - spec/ruby_llm/chat_streaming_spec.rb
51
+ - spec/ruby_llm/chat_tools_spec.rb
52
+ - spec/ruby_llm/embedding_spec.rb
53
+ - spec/ruby_llm/image_spec.rb
54
+ - spec/ruby_llm/models_spec.rb
55
+ - spec/ruby_llm/moderation_spec.rb
56
+ - spec/ruby_llm/providers/apfel_spec.rb
57
+ - spec/ruby_llm/rerank_spec.rb
58
+ - spec/ruby_llm/speech_spec.rb
59
+ - spec/ruby_llm/video_spec.rb
60
+ - spec/spec_helper.rb
61
+ - spec/support/models.rb
62
+ - spec/support/rubyllm_configuration.rb
63
+ - spec/support/vcr_configuration.rb
64
+ homepage: https://github.com/madbomber/ruby_llm-providers-apfel
65
+ licenses:
66
+ - MIT
67
+ metadata:
68
+ homepage_uri: https://github.com/madbomber/ruby_llm-providers-apfel
69
+ source_code_uri: https://github.com/madbomber/ruby_llm-providers-apfel
70
+ changelog_uri: https://github.com/madbomber/ruby_llm-providers-apfel/releases
71
+ bug_tracker_uri: https://github.com/madbomber/ruby_llm-providers-apfel/issues
72
+ rubygems_mfa_required: 'true'
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '3.1'
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 4.0.20
88
+ specification_version: 4
89
+ summary: RubyLLM provider for Apfel.
90
+ test_files: []