A portal-grade Go SDK for LLM apps that would prefer not to learn six provider dialects before lunch.
OpenAI lives in one dimension. Anthropic insists the couch goes over there. Gemini brought its own adapter. Ollama is running locally in the garage and refuses to put on shoes. Wormhole gives your Go application one control panel for the useful parts: text, streaming, structured output, embeddings, tool calling, image generation, audio, middleware, fallback, batching, and an OpenAI-compatible proxy.
It is not trying to replace every official provider SDK. That way lies a wall of generated clients, beta endpoints, and a whiteboard that just says "why?" in three colors. Wormhole handles the app-facing path. If you need provider-admin resources like files, batches, fine-tuning, vector stores, assistants, or realtime APIs, use the provider SDKs or REST APIs directly.
go get github.com/garyblankenship/wormhole@latestpackage main
import (
"context"
"fmt"
"os"
"github.com/garyblankenship/wormhole/pkg/wormhole"
)
func main() {
ctx := context.Background()
client := wormhole.New(
wormhole.WithDefaultProvider("openai"),
wormhole.WithOpenAI(os.Getenv("OPENAI_API_KEY")),
)
defer client.Close()
resp, err := client.Text().
Model("gpt-5.2").
Prompt("Explain wormholes in one sentence.").
Generate(ctx)
if err != nil {
panic(err)
}
fmt.Println(resp.Content())
}For one-off experiments, QuickText opens a temporary portal, sends the prompt,
and closes the blast door:
resp, err := wormhole.QuickText("gpt-5.2", "Hello", os.Getenv("OPENAI_API_KEY"))The common stuff is one builder chain away. No provider SDK séance, no "just this one special client" taped to the side of your service.
| Workflow | API |
|---|---|
| Text generation | client.Text().Model("gpt-5.2").Prompt("...").Generate(ctx) |
| Streaming | client.Text().Model("gpt-5.2").Prompt("...").Stream(ctx) |
| Stream and collect | chunks, fullText, err := builder.StreamAndAccumulate(ctx) |
| Structured output | client.Structured().Model("gpt-5.2").Schema(schema).GenerateAs(ctx, &out) |
| Embeddings | client.Embeddings().Model("text-embedding-3-small").Input("...").Generate(ctx) |
| Image generation | client.Image().Model("gpt-image-1").Prompt("...").Generate(ctx) |
| Speech to text | client.Audio().SpeechToText().Model("whisper-1").Audio(data, "wav").Transcribe(ctx) |
| Text to speech | client.Audio().TextToSpeech().Model("tts-1").Input("...").Voice("alloy").Generate(ctx) |
| Tool calling | wormhole.RegisterTypedTool(client, name, desc, handler) |
| Agent loop | client.Agent().Model("gpt-5.2").Run(ctx, "task") |
| Model fallback | client.Text().Model("gpt-5.2").WithFallback("gpt-5-mini").Generate(ctx) |
| Batch execution | client.Batch().Add(req1).Add(req2).Concurrency(5).Execute(ctx) |
| OpenAI-compatible endpoint | client.Text().BaseURL("http://localhost:11434/v1").Generate(ctx) |
| Provider capabilities | client.ProviderCapabilities("openai").SupportsToolCalling() |
Wormhole talks to providers through local HTTP adapters. No official OpenAI, Anthropic, Gemini, or Ollama SDK is bolted into the runtime. The dependency tree does not need a second garage.
| Provider | Configuration | Supported core resources |
|---|---|---|
| OpenAI | WithOpenAI(key) |
text, streaming, structured output, embeddings, images, audio, tools |
| Anthropic | WithAnthropic(key) |
text, streaming, structured output, tools, vision input |
| Gemini | WithGemini(key) |
text, streaming, structured output, embeddings, tools, vision input |
| Ollama | WithOllama(config) |
text, streaming, structured output, embeddings, local model helpers |
| OpenRouter | WithOpenAICompatible(...) or QuickOpenRouter() |
OpenAI-compatible text, streaming, structured output, tools where supported |
| Groq | WithGroq(key) |
OpenAI-compatible text and streaming |
| Mistral | WithMistral(config) |
OpenAI-compatible text and streaming |
| LM Studio | WithLMStudio(config) |
OpenAI-compatible local text and streaming |
| vLLM | WithVLLM(config) |
OpenAI-compatible local text and streaming |
| Custom | WithCustomProvider(name, factory) |
whatever your provider implements |
Use Wormhole when you want a common application API across providers. Use the official provider SDKs or direct REST calls when you need provider-admin or platform-specific resources:
- OpenAI Responses, Assistants, Threads, Runs, Files, Vector Stores, Batches, Fine-tuning, Moderation, Realtime, image edits, or audio translation.
- Anthropic Files API, Message Batches, provider beta resources, Bedrock, Vertex, or AWS platform adapters.
- Gemini Enterprise or Vertex-specific resources, files/caches beyond the core generation and embedding paths.
- Full Ollama model administration beyond the helper methods exposed on the concrete Ollama provider.
That boundary is deliberate. A stable app-facing API is useful. Rebuilding every
provider's entire space station by hand is how a normal Thursday becomes a
three-week expedition into final_final_rewrite_3.
client := wormhole.New(
wormhole.WithDefaultProvider("anthropic"),
wormhole.WithOpenAI(os.Getenv("OPENAI_API_KEY")),
wormhole.WithAnthropic(os.Getenv("ANTHROPIC_API_KEY")),
wormhole.WithGemini(os.Getenv("GEMINI_API_KEY")),
wormhole.WithTimeout(30*time.Second),
)Environment-backed helpers are available when you want the client to assemble itself from the usual keys and stop asking where the screwdriver went:
client := wormhole.New(wormhole.WithAllProvidersFromEnv())Common environment variables:
| Variable | Used for |
|---|---|
OPENAI_API_KEY |
OpenAI |
ANTHROPIC_API_KEY |
Anthropic |
GEMINI_API_KEY or GOOGLE_API_KEY |
Gemini |
OPENROUTER_API_KEY |
OpenRouter |
GROQ_API_KEY |
Groq |
MISTRAL_API_KEY |
Mistral |
OLLAMA_BASE_URL |
Ollama native API |
LMSTUDIO_BASE_URL |
LM Studio |
WORMHOLE_API_KEY |
Optional proxy bearer token |
Never hardcode provider keys in source code. The multiverse already has enough ways to ruin your week; leaked credentials do not need to audition.
resp, err := client.Text().
Model("gpt-5.2").
SystemPrompt("You are concise.").
Prompt("Summarize Go interfaces.").
MaxTokens(200).
Temperature(0.2).
Generate(ctx)stream, err := client.Text().
Model("gpt-5.2").
Prompt("Write a short haiku about latency.").
Stream(ctx)
if err != nil {
return err
}
for chunk := range stream {
if chunk.HasError() {
return chunk.Error
}
fmt.Print(chunk.Content())
}conv := types.NewConversation().
System("You are a careful code reviewer.").
User("Review this function.").
Assistant("Paste the function.").
User("func add(a, b int) int { return a + b }")
resp, err := client.Text().Conversation(conv).Model("gpt-5.2").Generate(ctx)type Verdict struct {
Status string `json:"status"`
Confidence float64 `json:"confidence"`
Reason string `json:"reason"`
}
var out Verdict
err := client.Structured().
Model("gpt-5.2").
Prompt("Classify this bug report as valid or invalid.").
Schema(wormhole.MustSchemaFromStruct(Verdict{})).
GenerateAs(ctx, &out)Structured output uses the best provider-specific path available: JSON mode, tool calling, or schema-backed generation depending on the provider. You ask for a shape; Wormhole handles the provider dialect and tries to keep the glowing liquid in the beaker.
resp, err := client.Embeddings().
Model("text-embedding-3-small").
Input("first document", "second document").
Dimensions(512).
Generate(ctx)
for _, emb := range resp.Embeddings {
fmt.Println(emb.Index, len(emb.Embedding))
}Provider notes:
| Provider | Notes |
|---|---|
| OpenAI | Supports Dimensions() for compatible embedding models. |
| Gemini | Supports embedding task metadata through ProviderOptions. |
| Ollama | Processes local embedding models through the native Ollama API. |
| OpenAI-compatible | Works when the endpoint implements /embeddings. |
OpenAI image generation:
img, err := client.Image().
Using("openai").
Model("gpt-image-1").
Prompt("A clean architecture diagram for an LLM gateway").
Size("1024x1024").
ResponseFormat("url").
Generate(ctx)OpenAI speech to text:
transcript, err := client.Audio().
Using("openai").
SpeechToText().
Model("whisper-1").
Audio(wavBytes, "wav").
Language("en").
Transcribe(ctx)OpenAI text to speech:
speech, err := client.Audio().
Using("openai").
TextToSpeech().
Model("tts-1").
Input("Ship small diffs.").
Voice("alloy").
ResponseFormat("mp3").
Generate(ctx)Define a Go struct and register a typed handler. Wormhole derives the tool
schema, executes tool calls, and feeds results back to the model. No hand-rolled
JSON schema séance required. The model asks for a tool, your Go code runs, the
result goes back through the portal, and nobody has to cast map[string]any
under fluorescent lighting.
type WeatherArgs struct {
City string `json:"city" tool:"required" desc:"City name"`
Unit string `json:"unit" tool:"enum=celsius,fahrenheit" desc:"Temperature unit"`
}
type WeatherResult struct {
Summary string `json:"summary"`
}
err := wormhole.RegisterTypedTool(client, "get_weather", "Get current weather",
func(ctx context.Context, args WeatherArgs) (WeatherResult, error) {
return WeatherResult{Summary: "Foggy, 58F"}, nil
},
)
if err != nil {
return err
}
resp, err := client.Text().
Model("gpt-5.2").
Prompt("What is the weather in San Francisco?").
WithToolsEnabled().
Generate(ctx)For manual tool handling, call WithToolsDisabled() and inspect
resp.ToolCalls.
Agents run multiple tool-use steps until the model reaches a final answer or the step limit is hit. Think less "magic autonomous intern" and more "bounded loop with tools, telemetry, and a stop condition." The important part is the stop condition. The garage has rules now.
result, err := client.Agent().
Model("gpt-5.2").
System("You are a research assistant.").
MaxSteps(10).
OnStep(func(e wormhole.StepEvent) {
fmt.Printf("step=%d tools=%d done=%v\n", e.Step, len(e.ToolCalls), e.Done)
}).
Run(ctx, "Compare today's provider options for a low-latency chat app.")Agent-scoped tools are available through AgentAddTool:
builder := client.Agent().Model("gpt-5.2")
err := wormhole.AgentAddTool(builder, "search", "Search local docs",
func(ctx context.Context, args SearchArgs) (string, error) {
return searchDocs(args.Query), nil
},
)Wormhole has provider middleware for retrying, timeouts, metrics, logging, rate-limiting, circuit breaking, caching, and health-aware routing. This is the part where the prototype gets seatbelts, brakes, and a dashboard light that means something.
openAIConfig := types.NewProviderConfig(os.Getenv("OPENAI_API_KEY")).
WithRetries(2, 200*time.Millisecond).
WithMaxRetryDelay(5 * time.Second)
client := wormhole.New(
wormhole.WithDefaultProvider("openai"),
wormhole.WithOpenAI(os.Getenv("OPENAI_API_KEY"), openAIConfig),
wormhole.WithProviderMiddleware(
middleware.NewTypedTimeoutMiddleware(30*time.Second),
),
)Adaptive concurrency can be enabled per client. It watches latency and adjusts capacity instead of sleeping for a random second and hoping the universe becomes emotionally available:
client.EnableAdaptiveConcurrency(&wormhole.EnhancedAdaptiveConfig{
MinCapacity: 2,
MaxCapacity: 50,
TargetLatency: 500 * time.Millisecond,
})Graceful shutdown drains in-flight requests:
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = client.Shutdown(ctx)Idempotency caches duplicate requests with the same key for the configured TTL:
client := wormhole.New(
wormhole.WithOpenAI(os.Getenv("OPENAI_API_KEY")),
wormhole.WithIdempotencyKey("request-123", 5*time.Minute),
)The wormhole binary can run a local OpenAI-compatible proxy. Point OpenAI-style
clients at one address and route models across provider dimensions with
prefixes.
go build -o wormhole ./cmd/wormhole
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=sk-ant-...
export GEMINI_API_KEY=...
export OLLAMA_BASE_URL=http://localhost:11434
./wormhole serve --addr :8080 --default-provider openaiModel prefixes select a provider:
| Request model | Provider | Sent model |
|---|---|---|
anthropic/claude-sonnet-4-5 |
Anthropic | claude-sonnet-4-5 |
gemini/gemini-2.5-pro |
Gemini | gemini-2.5-pro |
ollama/llama3.2 |
Ollama | llama3.2 |
gpt-5.2 |
default provider | gpt-5.2 |
Supported proxy endpoints:
| Method | Path |
|---|---|
POST |
/v1/chat/completions |
POST |
/v1/embeddings |
GET |
/v1/models |
GET |
/health |
Set WORMHOLE_API_KEY to require Authorization: Bearer <token> on proxy
requests. Do that before exposing the portal outside localhost unless your
incident-review strategy is "pretend the timeline never happened."
OpenAI-compatible providers only need a name and base URL. Congratulations, you have installed a new dimension:
client := wormhole.New(
wormhole.WithOpenAICompatible("perplexity", "https://api.perplexity.ai", types.ProviderConfig{
APIKey: os.Getenv("PERPLEXITY_API_KEY"),
}),
)For non-compatible providers, implement types.Provider and register a factory.
Yes, you can bring your own weird machine:
client := wormhole.New(
wormhole.WithCustomProvider("internal", func(config types.ProviderConfig) (types.Provider, error) {
return NewInternalProvider(config), nil
}),
wormhole.WithProviderConfig("internal", types.ProviderConfig{}),
wormhole.WithDefaultProvider("internal"),
)Use the mock provider to test application logic without network calls. Burning real tokens to unit-test branching logic is not science; it is a billing event.
import wmtest "github.com/garyblankenship/wormhole/pkg/testing"
func TestSummarize(t *testing.T) {
mock := wmtest.NewMockProvider("openai").
WithTextResponse(wmtest.TextResponseWith("mock response"))
client := wormhole.New(
wormhole.WithCustomProvider("openai", wmtest.MockProviderFactory(mock)),
wormhole.WithProviderConfig("openai", types.ProviderConfig{}),
wormhole.WithDefaultProvider("openai"),
)
resp, err := client.Text().Model("test-model").Prompt("test").Generate(context.Background())
require.NoError(t, err)
require.Equal(t, "mock response", resp.Content())
}Project checks:
make test-short
make test
go test ./...git clone https://github.com/garyblankenship/wormhole
cd wormhole
go test -short ./...
go test ./...Benchmarks:
make bench
go test -bench=. -benchmem ./pkg/wormhole- Read provider credentials from environment variables or a secret manager.
- Do not log raw request headers or API keys.
- Use HTTPS for remote provider endpoints.
- Set context deadlines or client timeouts for production requests.
- Use
WORMHOLE_API_KEYbefore exposing the proxy outside localhost. - Treat logs like hazardous waste: useful, but not where secrets belong.
MIT. See LICENSE. If the portal opens, that part is on you.
