Adding a provider¶
A "provider" is an LLM upstream Pluma can talk to. Today's set: OpenAIProvider (handles every OpenAI-compatible backend) and AnthropicProvider (talks the Messages API natively, translates back to OAI shape).
This page walks through adding a new one. The future plugin-axis story keeps the same interface at the call site, so what you write here ports forward unchanged.
The interface¶
// server/providers.go
type Provider interface {
ID() string
Upstream() string
ListModels(ctx context.Context) ([]ModelInfo, error)
StreamChat(ctx context.Context, req ChatRequest, onLine func(string) error) error
}
| Method | Returns |
|---|---|
ID() |
Short stable string. Matches the compat field in connections.json. Used in logs. |
Upstream() |
The base URL the provider was constructed with. For diagnostics + the health endpoint. |
ListModels() |
Models the upstream advertises through whatever its model-list endpoint is. |
StreamChat() |
Sends the request, calls onLine with each SSE line from the upstream (or synthesises SSE-shaped lines from non-streaming responses). |
Skeleton¶
package main
import (
"context"
"net/http"
)
type MyProvider struct {
baseURL string
apiKey string
client *http.Client
}
func NewMyProvider(profile ConnectionProfile, client *http.Client) Provider {
return &MyProvider{
baseURL: profile.BaseURL,
apiKey: profile.APIKey, // resolved from keyring by the caller
client: client,
}
}
func (p *MyProvider) ID() string { return "mybackend" }
func (p *MyProvider) Upstream() string { return p.baseURL }
func (p *MyProvider) ListModels(ctx context.Context) ([]ModelInfo, error) {
// Hit your upstream's model-list endpoint; map to ModelInfo.
// ModelInfo = { id, display, family }.
return nil, nil
}
func (p *MyProvider) StreamChat(ctx context.Context, req ChatRequest, onLine func(string) error) error {
// POST to your upstream.
// For each chunk in the response, call onLine("data: <json>") with an
// OAI-shaped delta. End with onLine("data: [DONE]").
// Returning an error stops the stream + emits an error event to the FE.
return nil
}
Registration¶
server/providers.go keeps a factory map. Add yours in init():
The connection profile's compat field picks the factory. Update the UI's compat dropdown in web/src/components/SettingsConnections.svelte to include the new option.
Translating non-OAI shapes¶
If your upstream doesn't speak OpenAI streaming SSE, translate inside StreamChat. The pattern from AnthropicProvider:
- Build the upstream-native request body.
- Stream the response.
- For each chunk, convert to an OAI-shape
chat.completion.chunkJSON:{"choices":[{"delta":{"content":"..."}}]}. - Emit
data: <json>\nviaonLine. - After the last chunk:
onLine("data: [DONE]").
The FE doesn't know the upstream isn't OAI.
Testing¶
A test file at server/myprovider_test.go. The pattern:
func TestMyProviderStreamChat(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// emit your upstream's SSE shape
}))
defer srv.Close()
p := NewMyProvider(ConnectionProfile{BaseURL: srv.URL}, http.DefaultClient)
var got []string
err := p.StreamChat(context.Background(), ChatRequest{ /* ... */ }, func(line string) error {
got = append(got, line)
return nil
})
// assert err is nil, got contains the OAI-shape lines you expected
}
make test-go picks it up.
Authentication¶
API keys live in the OS keyring under connection:<profile-id>. By the time NewMyProvider runs, the caller (ConnectionManager.Provider()) has already resolved the key into profile.APIKey. Use it as a normal string; don't reach back into the keyring yourself.
Streaming SSE format¶
OpenAI's chat.completion.chunk shape Pluma's FE expects:
data: {"choices":[{"delta":{"role":"assistant"}}]}
data: {"choices":[{"delta":{"content":"Hello"}}]}
data: {"choices":[{"delta":{"content":" world"}}]}
data: [DONE]
Empty lines between data: lines are fine; the FE parses one event per data: line. The [DONE] sentinel signals end-of-stream.
Errors: throw an error from StreamChat; the chat handler emits an event: error\ndata: <message> SSE event the FE renders inline.
Plugin axis (future)¶
When the plugin axis (smelt-7bq) lands, the Provider interface stays at the call site. Built-in providers (like yours) keep registering at init() against an in-process bypass. Community providers register over the gRPC bus by implementing the matching protobuf service. Same interface either way.