Skip to content

Commit 5668157

Browse files
authored
feat(provider): add Llamafile provider and OpenAI-compatible base package (#20)
* test: add Llamafile model mappings to test fixtures * feat: add OpenAI-compatible base provider package Adds a reusable base provider for services that expose OpenAI-compatible APIs but don't have their own Go SDK. This reduces code duplication for providers like Llamafile, vLLM, LM Studio, etc. * feat: add Llamafile provider implementation Llamafile is a single-file executable that bundles a model with llama.cpp, exposing an OpenAI-compatible API. This provider uses the openaicompat base package since Llamafile doesn't have its own Go SDK. * test: add Llamafile provider tests * docs: add Llamafile to supported providers documentation * docs: update CLAUDE.md with PR feedback patterns
1 parent 088695d commit 5668157

File tree

11 files changed

+1582
-562
lines changed

11 files changed

+1582
-562
lines changed

CLAUDE.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,22 @@ Providers implement `ErrorConverter` using `errors.As` with SDK typed errors (no
8383
### Key Patterns
8484

8585
- **Configuration**: Functional options with validation
86-
- **Constants**: Extract ALL magic strings to named constants
86+
- **Constants**: Extract ALL magic strings to named constants (including response format types like `json_object`)
8787
- **Streaming**: Break monolithic handlers into focused methods (see `anthropic/anthropic.go`)
88+
- **Streaming Safety**: Always use `select` with `ctx.Done()` when sending to channels in goroutines to prevent blocking forever if consumer abandons
8889
- **ID Generation**: Use `crypto/rand`, not package-level mutable state
89-
- **Error Conversion**: Use `errors.As` with SDK typed errors
90+
- **Error Conversion**: Use `errors.As` with SDK typed errors; avoid string matching when possible
91+
- **Input Validation**: Validate required fields (Model non-empty, Messages has entries) before API calls
92+
- **Unknown Values**: Never silently convert unknown enum values (e.g., unknown role → user); error or log warning instead
93+
- **Struct Field Order**: Order struct fields A-Z (don't optimize for padding)
94+
95+
### OpenAI-Compatible Providers
96+
97+
For providers that expose OpenAI-compatible APIs but don't have their own Go SDK (Llamafile, vLLM, LM Studio, etc.):
98+
- Use the compatible provider in `providers/openai/compatible.go`
99+
- Import: `"github.com/mozilla-ai/any-llm-go/providers/openai"`
100+
- Create thin wrapper that calls `openai.NewCompatible()` with provider-specific `CompatibleConfig`
101+
- Add interface assertions in the wrapper package
90102

91103
### Testing
92104

@@ -96,6 +108,8 @@ Providers implement `ErrorConverter` using `errors.As` with SDK typed errors (no
96108
- Name test case variable `tc`, not `tt`
97109
- Name helpers/mocks with `test`, `mock`, `fake` to distinguish from production code
98110
- Skip integration tests gracefully when provider unavailable
111+
- Use constants (e.g., `objectChatCompletion`) instead of string literals in test assertions
112+
- Base packages need their own test suites, not just wrapper tests
99113

100114
## Adding a New Provider
101115

config/config.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,41 @@ func (c *Config) ResolveAPIKey(envVar string) string {
169169

170170
return os.Getenv(envVar)
171171
}
172+
173+
// ResolveEnv returns the value of the specified environment variable,
174+
// trimming whitespace. Returns empty string if the variable is not set or empty.
175+
func (c *Config) ResolveEnv(envVar string) string {
176+
if envVar == "" {
177+
return ""
178+
}
179+
return strings.TrimSpace(os.Getenv(envVar))
180+
}
181+
182+
// ResolveBaseURL resolves the base URL from config, environment variable, or default value.
183+
// It validates that the resolved URL has a scheme and host.
184+
func (c *Config) ResolveBaseURL(envVar, defaultVal string) (string, error) {
185+
baseURL := c.BaseURL
186+
if baseURL == "" {
187+
baseURL = c.ResolveEnv(envVar)
188+
}
189+
if baseURL == "" {
190+
baseURL = defaultVal
191+
}
192+
193+
if baseURL == "" {
194+
return "", nil
195+
}
196+
197+
baseURL = strings.TrimSpace(baseURL)
198+
199+
parsed, err := url.Parse(baseURL)
200+
if err != nil {
201+
return "", fmt.Errorf("invalid base URL %q: %w", baseURL, err)
202+
}
203+
204+
if parsed.Scheme == "" || parsed.Host == "" {
205+
return "", fmt.Errorf("base URL %q must have scheme and host", baseURL)
206+
}
207+
208+
return baseURL, nil
209+
}

config/config_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,87 @@ func TestResolveAPIKey(t *testing.T) {
479479
}
480480
}
481481

482+
func TestResolveEnv(t *testing.T) {
483+
// Note: Cannot use t.Parallel() with t.Setenv().
484+
485+
t.Run("returns trimmed env value", func(t *testing.T) {
486+
t.Setenv("TEST_RESOLVE_ENV", " some-value ")
487+
488+
cfg := &Config{}
489+
result := cfg.ResolveEnv("TEST_RESOLVE_ENV")
490+
require.Equal(t, "some-value", result)
491+
})
492+
493+
t.Run("returns empty for unset variable", func(t *testing.T) {
494+
cfg := &Config{}
495+
result := cfg.ResolveEnv("TEST_RESOLVE_ENV_UNSET")
496+
require.Empty(t, result)
497+
})
498+
499+
t.Run("returns empty for empty env var name", func(t *testing.T) {
500+
cfg := &Config{}
501+
result := cfg.ResolveEnv("")
502+
require.Empty(t, result)
503+
})
504+
}
505+
506+
func TestResolveBaseURL(t *testing.T) {
507+
// Note: Cannot use t.Parallel() with t.Setenv().
508+
509+
t.Run("uses config BaseURL first", func(t *testing.T) {
510+
cfg := &Config{BaseURL: "https://config.example.com/v1"}
511+
result, err := cfg.ResolveBaseURL("", "https://default.example.com/v1")
512+
require.NoError(t, err)
513+
require.Equal(t, "https://config.example.com/v1", result)
514+
})
515+
516+
t.Run("falls back to env var", func(t *testing.T) {
517+
t.Setenv("TEST_BASE_URL_RESOLVE", "https://env.example.com/v1")
518+
519+
cfg := &Config{}
520+
result, err := cfg.ResolveBaseURL("TEST_BASE_URL_RESOLVE", "https://default.example.com/v1")
521+
require.NoError(t, err)
522+
require.Equal(t, "https://env.example.com/v1", result)
523+
})
524+
525+
t.Run("falls back to default", func(t *testing.T) {
526+
cfg := &Config{}
527+
result, err := cfg.ResolveBaseURL("", "https://default.example.com/v1")
528+
require.NoError(t, err)
529+
require.Equal(t, "https://default.example.com/v1", result)
530+
})
531+
532+
t.Run("returns empty when all empty", func(t *testing.T) {
533+
cfg := &Config{}
534+
result, err := cfg.ResolveBaseURL("", "")
535+
require.NoError(t, err)
536+
require.Empty(t, result)
537+
})
538+
539+
t.Run("returns error for invalid URL", func(t *testing.T) {
540+
cfg := &Config{BaseURL: "://bad-url"}
541+
_, err := cfg.ResolveBaseURL("", "")
542+
require.Error(t, err)
543+
require.Contains(t, err.Error(), "invalid base URL")
544+
})
545+
546+
t.Run("returns error for URL without scheme", func(t *testing.T) {
547+
cfg := &Config{BaseURL: "example.com/v1"}
548+
_, err := cfg.ResolveBaseURL("", "")
549+
require.Error(t, err)
550+
require.Contains(t, err.Error(), "must have scheme and host")
551+
})
552+
553+
t.Run("trims whitespace from resolved URL", func(t *testing.T) {
554+
t.Setenv("TEST_BASE_URL_WS", " https://env.example.com/v1 ")
555+
556+
cfg := &Config{}
557+
result, err := cfg.ResolveBaseURL("TEST_BASE_URL_WS", "")
558+
require.NoError(t, err)
559+
require.Equal(t, "https://env.example.com/v1", result)
560+
})
561+
}
562+
482563
func TestHTTPClientCaching(t *testing.T) {
483564
t.Parallel()
484565

docs/providers.md

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ any-llm-go supports multiple LLM providers through a unified interface. Each pro
99
| [OpenAI](#openai) | `openai` |||||||
1010
| [Anthropic](#anthropic) | `anthropic` |||||||
1111
| [Ollama](#ollama) | `ollama` |||||||
12+
| [Llamafile](#llamafile) | `llamafile` |||||||
1213

1314
### Legend
1415

@@ -156,6 +157,75 @@ for _, model := range models.Data {
156157
}
157158
```
158159

160+
### Llamafile
161+
162+
Llamafile is a single-file executable that bundles a model with llama.cpp for easy local deployment. It exposes an OpenAI-compatible API. No API key is required.
163+
164+
```go
165+
import (
166+
anyllm "github.com/mozilla-ai/any-llm-go"
167+
"github.com/mozilla-ai/any-llm-go/providers/llamafile"
168+
)
169+
170+
// Using default settings (localhost:8080/v1).
171+
provider, err := llamafile.New()
172+
173+
// Or with custom base URL.
174+
provider, err := llamafile.New(anyllm.WithBaseURL("http://localhost:8081/v1"))
175+
```
176+
177+
**Environment Variable:** `LLAMAFILE_BASE_URL` (optional, defaults to `http://localhost:8080/v1`)
178+
179+
**Running Llamafile:**
180+
181+
Download a llamafile from [Mozilla-Ocho/llamafile](https://github.com/Mozilla-Ocho/llamafile) and run it:
182+
183+
```bash
184+
# Download a llamafile (example: LLaVA)
185+
curl -LO https://huggingface.co/Mozilla/llava-v1.5-7b-llamafile/resolve/main/llava-v1.5-7b-q4.llamafile
186+
chmod +x llava-v1.5-7b-q4.llamafile
187+
./llava-v1.5-7b-q4.llamafile --server
188+
```
189+
190+
**Completion:**
191+
192+
```go
193+
provider, _ := llamafile.New()
194+
resp, err := provider.Completion(ctx, anyllm.CompletionParams{
195+
Model: "LLaMA_CPP", // Llamafile uses "LLaMA_CPP" as the model name.
196+
Messages: []anyllm.Message{
197+
{Role: anyllm.RoleUser, Content: "Hello!"},
198+
},
199+
})
200+
```
201+
202+
**Streaming:**
203+
204+
```go
205+
provider, _ := llamafile.New()
206+
chunks, errs := provider.CompletionStream(ctx, anyllm.CompletionParams{
207+
Model: "LLaMA_CPP",
208+
Messages: messages,
209+
})
210+
211+
for chunk := range chunks {
212+
fmt.Print(chunk.Choices[0].Delta.Content)
213+
}
214+
if err := <-errs; err != nil {
215+
log.Fatal(err)
216+
}
217+
```
218+
219+
**List Models:**
220+
221+
```go
222+
provider, _ := llamafile.New()
223+
models, err := provider.ListModels(ctx)
224+
for _, model := range models.Data {
225+
fmt.Println(model.ID) // Typically "LLaMA_CPP"
226+
}
227+
```
228+
159229
## Coming Soon
160230

161231
The following providers are planned for future releases:
@@ -168,7 +238,6 @@ The following providers are planned for future releases:
168238
| Cohere | Planned |
169239
| Together AI | Planned |
170240
| AWS Bedrock | Planned |
171-
| Llamafile | Planned |
172241
| Azure OpenAI | Planned (use OpenAI with custom base URL for now) |
173242

174243
## Adding a New Provider

internal/testutil/fixtures.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ var ProviderModelMap = map[string]string{
1818
"cohere": "command-r",
1919
"groq": "llama-3.1-8b-instant",
2020
"ollama": "llama3.2",
21+
"llamafile": "LLaMA_CPP",
2122
"together": "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
2223
"perplexity": "llama-3.1-sonar-small-128k-online",
2324
"deepseek": "deepseek-chat",
@@ -46,11 +47,12 @@ var ProviderImageModelMap = map[string]string{
4647

4748
// EmbeddingProviderModelMap maps providers to embedding models.
4849
var EmbeddingProviderModelMap = map[string]string{
49-
"openai": "text-embedding-3-small",
50-
"cohere": "embed-english-v3.0",
51-
"mistral": "mistral-embed",
52-
"together": "togethercomputer/m2-bert-80M-8k-retrieval",
53-
"ollama": "nomic-embed-text",
50+
"openai": "text-embedding-3-small",
51+
"cohere": "embed-english-v3.0",
52+
"mistral": "mistral-embed",
53+
"together": "togethercomputer/m2-bert-80M-8k-retrieval",
54+
"ollama": "nomic-embed-text",
55+
"llamafile": "LLaMA_CPP",
5456
}
5557

5658
// ProviderClientConfig holds provider-specific configuration for tests.

providers/llamafile/llamafile.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Package llamafile provides a Llamafile provider implementation for any-llm.
2+
// Llamafile is a single-file executable that bundles a model with llama.cpp,
3+
// exposing an OpenAI-compatible API.
4+
package llamafile
5+
6+
import (
7+
"github.com/mozilla-ai/any-llm-go/config"
8+
"github.com/mozilla-ai/any-llm-go/providers"
9+
"github.com/mozilla-ai/any-llm-go/providers/openai"
10+
)
11+
12+
// Provider configuration constants.
13+
const (
14+
defaultAPIKey = "llamafile" // Dummy key; Llamafile doesn't require auth.
15+
defaultBaseURL = "http://localhost:8080/v1"
16+
envBaseURL = "LLAMAFILE_BASE_URL"
17+
providerName = "llamafile"
18+
)
19+
20+
// Ensure Provider implements the required interfaces.
21+
var (
22+
_ providers.CapabilityProvider = (*Provider)(nil)
23+
_ providers.EmbeddingProvider = (*Provider)(nil)
24+
_ providers.ErrorConverter = (*Provider)(nil)
25+
_ providers.ModelLister = (*Provider)(nil)
26+
_ providers.Provider = (*Provider)(nil)
27+
)
28+
29+
// Provider implements the providers.Provider interface for Llamafile.
30+
// It embeds openai.CompatibleProvider since Llamafile exposes an OpenAI-compatible API.
31+
type Provider struct {
32+
*openai.CompatibleProvider
33+
}
34+
35+
// New creates a new Llamafile provider.
36+
func New(opts ...config.Option) (*Provider, error) {
37+
base, err := openai.NewCompatible(openai.CompatibleConfig{
38+
APIKeyEnvVar: "", // Llamafile doesn't use an API key env var.
39+
BaseURLEnvVar: envBaseURL,
40+
Capabilities: llamafileCapabilities(),
41+
DefaultAPIKey: defaultAPIKey,
42+
DefaultBaseURL: defaultBaseURL,
43+
Name: providerName,
44+
RequireAPIKey: false,
45+
}, opts...)
46+
if err != nil {
47+
return nil, err
48+
}
49+
50+
return &Provider{CompatibleProvider: base}, nil
51+
}
52+
53+
// llamafileCapabilities returns the capabilities for the Llamafile provider.
54+
func llamafileCapabilities() providers.Capabilities {
55+
return providers.Capabilities{
56+
Completion: true,
57+
CompletionImage: true, // Depends on the model loaded.
58+
CompletionPDF: false,
59+
CompletionReasoning: false, // Llamafile doesn't support reasoning natively.
60+
CompletionStreaming: true,
61+
Embedding: true,
62+
ListModels: true,
63+
}
64+
}

0 commit comments

Comments
 (0)