@wrongstack/providers
v1.1.7
Published
WrongStack LLM provider adapters (Anthropic, OpenAI, Google) built on declarative WireFormatConfig.
Readme
@wrongstack/providers
LLM provider adapters for WrongStack: Anthropic, OpenAI, Google, OpenAI-compatible (Mistral, DeepSeek, xAI/Grok, Groq, Together, Fireworks, OpenRouter, …), plus interactive sign-in for ChatGPT, GitHub Copilot, and OpenRouter PKCE.
All providers ride a single declarative WireFormatConfig adapter — even the majors (Anthropic / OpenAI / Google) are thin wrappers around their presets. Adding a new OpenAI-compatible provider is usually a 20-line preset, not a new file.
Install
pnpm add @wrongstack/providers @wrongstack/core@wrongstack/core provides the shared Provider interface, message types, capabilities model, and tool format.
Provider definition authority
Product identity, endpoint, environment-variable, model, usage, compatibility-policy, local-server, and setup-card metadata live in the typed ProviderDefinition registry. Runtime presets, factory tuning, CLI/WebUI local presets, the WebUI setup catalog, and the generated provider catalog are projections of that registry.
After changing a definition, run pnpm providers:catalog:write. Release checks fail when either generated snapshot drifts.
For ChatGPT subscription cache identity, volatile prompt handling, upstream comparisons and WRONGSTACK_CACHE_PROBE diagnostics, see OpenAI cache management.
Supported wire families
| Family | Class | Preset | Used by |
|--------|-------|--------|---------|
| anthropic | AnthropicProvider | anthropicWireFormat | Claude API key |
| anthropic-oauth | RetiredAnthropicOAuthProvider | — | Retired Claude sign-in; refuses every request with guidance |
| openai | OpenAIProvider | openaiWireFormat | OpenAI API key |
| openai-codex | OpenAICodexProvider | — | ChatGPT (Responses API, OAuth) |
| github-copilot | GitHubCopilotProvider | — | GitHub Copilot (OAuth) |
| openai-compatible | OpenAICompatibleProvider | generic | Any /v1/chat/completions endpoint |
| google | GoogleProvider | googleWireFormat | Gemini API key |
Tuned presets (auto-registered for matching provider ids)
| Preset | Family | Base URL | Notes |
|--------|--------|----------|-------|
| mistralWireFormat | openai-compatible | https://api.mistral.ai/v1 | JSON mode, 128K context |
| ollamaWireFormat | openai-compatible | http://localhost:11434/v1 | No auth, keep-alive |
| vllmWireFormat | openai-compatible | http://localhost:8000/v1 | 32K context |
| lmstudioWireFormat | openai-compatible | http://localhost:1234/v1 | 8K context |
What's in here
src/
anthropic.ts thin WireFormatProvider wrapper (anthropic preset)
openai.ts thin WireFormatProvider wrapper (openai preset)
google.ts thin WireFormatProvider wrapper (google preset)
openai-compatible.ts generic /v1/chat/completions adapter
anthropic-oauth.ts Retired Claude sign-in (saved entries load, requests are refused)
openai-codex.ts ChatGPT Responses API (OAuth)
github-copilot.ts GitHub Copilot (OAuth)
wire-adapter.ts base HTTP + SSE streaming for all providers
wire-format.ts declarative WireFormatConfig → WireFormatProvider
presets/ tuned configs for anthropic / openai / google / mistral / local-llm
capabilities.ts resolve capabilities for (provider, model) pairs
family-capabilities.ts per-family capability defaults
sse.ts SSE parser with 256 KB buffer cap
aggregate.ts tool_use stream-event aggregator
tool-format/ tools ↔ Anthropic / OpenAI / Responses converters
stop-reason.ts normalize provider stop_reason → canonical
error-parse.ts parse provider HTTP error envelopes
index.ts factory builder + preset registrationRequest parameters
Every provider accepts the canonical Request type from @wrongstack/core. Parameters not supported by a provider are silently ignored at the wire level:
| Parameter | Anthropic | OpenAI | Gemini | Compatible |
|-----------|-----------|--------|--------|------------|
| model | ✅ | ✅ | ✅ | ✅ |
| messages | ✅ | ✅ | ✅ | ✅ |
| maxTokens | ✅ max_tokens | ✅ max_completion_tokens | ✅ maxOutputTokens | ✅ max_tokens |
| temperature | ✅ temperature | ✅ temperature | ✅ temperature | ✅ temperature |
| topP | ✅ top_p | ✅ top_p | ✅ topP | ✅ top_p |
| topK | ✅ top_k | ❌ | ✅ topK | ✅ top_k |
| frequencyPenalty | ❌ | ✅ frequency_penalty | ✅ frequencyPenalty | ✅ frequency_penalty |
| presencePenalty | ❌ | ✅ presence_penalty | ✅ presencePenalty | ✅ presence_penalty |
| seed | ❌ | ✅ seed | ✅ seed | ✅ seed |
| stopSequences | ✅ stop_sequences | ✅ stop | ✅ stopSequences | ✅ stop |
| toolChoice | ✅ | ✅ | ✅ | ✅ |
| user | ✅ (metadata.user_id) | ✅ user | ❌ | ✅ user |
| logprobs | ❌ | ✅ logprobs + top_logprobs | ✅ logprobs | ✅ logprobs |
| reasoning | ✅ thinking | ✅ reasoning_effort | ✅ thinkingConfig | via quirks |
| responseFormat | ❌ | ✅ response_format | ✅ responseMimeType+responseSchema | Provider-specific |
| cache | ✅ cache_control | ✅ automatic prompt_cache_key | ❌ | model-dependent automatic cache key |
| safetySettings | ❌ | ❌ | ✅ safetySettings array | ❌ |
| candidateCount | ❌ | ❌ | ✅ candidateCount | ❌ |
Quick example
import { AnthropicProvider } from '@wrongstack/providers';
const provider = new AnthropicProvider({
apiKey: process.env.ANTHROPIC_API_KEY!,
});
const stream = provider.stream(
{
model: 'anthropic-test-model',
messages: [{ role: 'user', content: 'hello' }],
maxTokens: 512,
temperature: 0.7,
topK: 40,
user: 'user-abc',
reasoning: { enabled: true, effort: 'high' },
},
{ signal: new AbortController().signal },
);
for await (const event of stream) {
if (event.type === 'text_delta') process.stdout.write(event.text);
}Using a preset (OpenAI-compatible service)
Presets are automatically used when the models.dev registry returns a matching provider id. You can also use them directly:
import { createWireFormatFactory, mistralWireFormat } from '@wrongstack/providers';
// Build a factory from the Mistral preset
const factory = createWireFormatFactory(mistralWireFormat, {
apiKey: process.env.MISTRAL_API_KEY!,
});
const provider = factory.create({ apiKey: process.env.MISTRAL_API_KEY! });
const result = await provider.complete(
{
model: 'mistral-large-2506',
messages: [{ role: 'user', content: 'hello' }],
maxTokens: 512,
seed: 42,
},
{ signal: new AbortController().signal },
);Wire-format adapter (declarative)
For a new provider that doesn't fit one of the existing presets, write a WireFormatConfig and plug it into WireFormatProvider. See docs/provider-author-guide.md for the full spec.
import { WireFormatProvider, type WireFormatConfig, defineWireFormat } from '@wrongstack/providers';
const myWire = defineWireFormat({
id: 'myprovider',
family: 'openai-compatible',
capabilities: { tools: true, parallelTools: true, vision: false, streaming: true, promptCache: false, systemPrompt: true, jsonMode: false, maxContext: 32_000, cacheControl: 'none' },
defaultBaseUrl: 'https://api.myprovider.com/v1',
buildUrl: (baseUrl) => `${baseUrl.replace(/\/+$/, '')}/chat/completions`,
buildHeaders: (apiKey) => ({ authorization: `Bearer ${apiKey}` }),
buildBody: (req, { capabilities }) => ({
model: req.model,
messages: req.messages,
max_tokens: req.maxTokens ?? capabilities.maxOutput ?? 8192,
stream: true,
}),
createStreamState: (fallbackModel) => ({ model: fallbackModel, started: false }),
parseStreamEvent: () => [],
finalizeStream: () => [{ type: 'message_stop', stopReason: 'end_turn', usage: { input: 0, output: 0 } }],
});
const provider = new WireFormatProvider(myWire, { apiKey: '…' });Capabilities model
Each provider family has a baseline capability profile in CAPABILITIES_BY_FAMILY. Per-model overrides from the models.dev catalog are AND-ed with the family baseline. The agent uses capabilities to gate vision, reasoning, tool-use, and parameter support.
Capability flags
| Flag | anthropic | openai | google | compatible |
|------|-----------|--------|--------|------------|
| tools / parallelTools | ✅ | ✅ | ✅ | ✅ |
| vision | ✅ | ✅ | ✅ | ❌ |
| streaming | ✅ | ✅ | ✅ | ✅ |
| systemPrompt | ✅ | ✅ | ✅ | ✅ |
| promptCache | ✅ (native) | ❌ | ❌ | ❌ |
| jsonMode | ❌ | ✅ | ✅ | ❌ |
| reasoning | ❌ (via thinking) | ✅ | via thinkingConfig | via quirks |
| topK | ✅ | ❌ | ✅ | ❌ |
| frequencyPenalty | ❌ | ✅ | ✅ | ❌ |
| presencePenalty | ❌ | ✅ | ✅ | ❌ |
| seed | ❌ | ✅ | ✅ | ❌ |
| structuredOutput | ❌ | ✅ | ✅ | ❌ |
| logprobs | ❌ | ✅ | ✅ | ❌ |
| audio | ❌ | ✅ (gpt-audio-*) | ❌ | ❌ |
User-defined model overrides via CustomModelDefinition.capabilities skip the AND gate and take full effect.
Thinking / reasoning
Extended thinking (chain-of-thought) is managed through the Request.reasoning field and works across providers:
Anthropic:
reasoning.enabled→thinking: { type: "enabled", budget_tokens }. The budget is derived automatically frommaxTokensandreasoning.effort. Thinking is streamed asthinking_start,thinking_delta,thinking_signature, andthinking_stopevents. The signature must be echoed back on the next request — the stream parser handles this automatically.OpenAI (o-series models):
reasoning.effort→reasoning_effort: "low"|"medium"|"high". Valuesminimal,xhigh, andmaxare filtered to avoid provider 400s. Reasoning content from the response (delta.reasoning_content) is echoed back asmessage.reasoning_contenton subsequent turns. Cerebras rejects that field and takesmessage.reasoninginstead:*.cerebras.aigetsreasoningautomatically, any other endpoint that 400s on the field is retried withreasoningand then without an echo (learned per model for the session), and thereasoningEchoFieldquirk (reasoning_content|reasoning|omit) pins it up front.Gemini:
reasoning.enabled→generationConfig.thinkingConfig: { type: "enabled"|"disabled" }.OpenAI-compatible: The
thinkingParamquirk controls how reasoning is mapped —'zai-glm'(ZAI reasoning),'kimi-toggle'(Kimi thinking mode), or'always-on'(models that always think and reject disable). SeeCompatibilityQuirks.
Structured output
Set Request.responseFormat to constrain the model's output:
// JSON Schema (OpenAI, Gemini)
{
type: 'json_schema',
jsonSchema: {
name: 'person',
schema: {
type: 'object',
properties: {
name: { type: 'string' },
age: { type: 'number' }
},
required: ['name', 'age']
}
}
}
// Free-form JSON (OpenAI, Gemini)
{ type: 'json_object' }
// Plain text (default)
{ type: 'text' }- OpenAI →
response_formatwithjson_schemaorjson_objecttype - Gemini →
responseMimeType: "application/json"+responseSchema(when json_schema)
Safety settings
Gemini supports per-category safety thresholds via Request.safetySettings:
{
safetySettings: [
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_ONLY_HIGH' },
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_LOW_AND_ABOVE' },
],
}Supported categories: HARM_CATEGORY_HARASSMENT, HARM_CATEGORY_HATE_SPEECH, HARM_CATEGORY_SEXUALLY_EXPLICIT, HARM_CATEGORY_DANGEROUS_CONTENT.
Thresholds: BLOCK_NONE, BLOCK_ONLY_HIGH, BLOCK_MEDIUM_AND_ABOVE, BLOCK_LOW_AND_ABOVE.
Structured error responses
Every provider HTTP error is normalized through parseProviderHttpError() into a ProviderError with:
status— HTTP status codeproviderId— which provider returned the errorretryable— whether the agent loop should retry (true for 429/5xx)describe()— one-line human-readable description for the CLI/TUI status line
Tool input parsing (parseToolInput)
All providers run tool-call JSON through _tool-input.ts. It guarantees the agent always receives a Record<string, unknown> for tool_use.input, never a parse-error or null. Invalid or non-object inputs are wrapped under { __raw: ... } instead of crashing the provider runner.
Local LLM presets
Presets for Ollama, vLLM, and LM Studio are tuned for local servers:
import { ollamaWireFormat } from '@wrongstack/providers';
import { WireFormatProvider } from '@wrongstack/providers';
const ollama = new WireFormatProvider(ollamaWireFormat, {
apiKey: '', // Ollama doesn't use auth
baseUrl: 'http://localhost:11434/v1',
});The createLocalLlmPreset() factory lets you create custom local presets with bodyExtras for provider-specific fields (e.g., Ollama's keep_alive, vLLM's repetition_penalty).
xAI account-aware models
The xai preset authenticates with XAI_API_KEY and discovers the account's
language models from GET https://api.x.ai/v1/language-models at boot. The
result is cached and merged over models.dev metadata; context windows,
modalities, and xAI's cents-per-100M-token prices are normalized into the shared
model schema. When the user has not configured an explicit visibility list,
the authenticated result becomes the session's visible-model allowlist.
Grok 4.5, 4.6, and 4.20 reasoning requests use xAI's supported effort
vocabulary. Internal minimal/max levels map to the nearest xAI values, and
xhigh is retained only for models that support it.
OAuth providers
Interactive provider authentication is registered independently from runtime
transports through ProviderAuthRegistry. CLI, TUI, and WebUI consume the same
UI-safe strategy metadata. Built-ins are ChatGPT, Claude, GitHub Copilot, and
OpenRouter.
Three runtime providers use OAuth token refresh patterns:
RetiredAnthropicOAuthProvider — what a saved
anthropic-oauthentry builds now. The Claude sign-in only worked by presenting WrongStack as Claude Code, so it was removed; every request fails with a non-retryableautherror pointing at an Anthropic API key (Max/Team plans include monthly API credits). Nothing is sent and the stored tokens are never read.OpenAICodexProvider — ChatGPT "Sign in with ChatGPT" (Responses API). Uses the OpenAI Responses wire format (not chat/completions). Reasoning effort is configurable via
reasoningEffort.GitHubCopilotProvider — GitHub Copilot subscription. Wraps the OpenAI-compatible chat endpoint with Copilot editor headers and token-based URL discovery.
All three persist refreshed tokens through the setOAuthTokenPersister hook so new tokens survive session restarts.
OpenRouter OAuth uses an ephemeral localhost callback plus S256 PKCE. Its
authorization code is exchanged for a user-controlled OpenRouter API key, so
there is no expiring access/refresh pair; the result is stored through the same
vault-backed ProviderApiKey path as manually entered keys.
License
MIT
