Skip to content

Writing Portable Code Across Providers

You want analysis code that works across providers. Switch from Gemini to OpenAI (or back) by changing a config line, not rewriting your pipeline. This page shows the patterns that make that work.

Pollux is capability-transparent, not capability-equalizing. All providers support the core text pipeline, but some features are provider-specific. For example, Gemini uses explicit caching (create_cache()), Anthropic uses implicit caching (Options(implicit_caching=True)), and OpenRouter exposes tool calling and structured outputs only on models that advertise those features. When you use an unsupported feature for a provider, Pollux raises a ConfigurationError or APIError. No silent degradation. This keeps behavior legible in both development and production.

Boundary

Pollux owns: translating your Config, Options, Source, and prompts into provider-specific API calls, and normalizing responses into a stable ResultEnvelope.

You own: choosing which features to use (sticking to the portable subset or gracefully degrading), selecting provider-appropriate models, and handling capability differences at the edges.

The Pattern

Portable code separates what varies (provider, model, provider-specific options) from what doesn't (prompts, sources, pipeline logic). Put the varying parts in config; keep the stable parts in functions.

Complete Example

A document analysis function that works on any provider.

import asyncio
from dataclasses import dataclass

from pydantic import BaseModel

from pollux import APIError, Config, ConfigurationError, Options, Source, run


class DocumentSummary(BaseModel):
    title: str
    key_points: list[str]
    word_count: int


@dataclass
class ProviderConfig:
    """Maps a provider to a model."""
    provider: str
    model: str


# Provider-specific details live here, not in your pipeline logic
PROVIDERS = {
    "gemini": ProviderConfig("gemini", "gemini-2.5-flash-lite"),
    "openai": ProviderConfig("openai", "gpt-5-nano"),
    "anthropic": ProviderConfig("anthropic", "claude-haiku-4-5"),
    "openrouter": ProviderConfig("openrouter", "openai/gpt-5-nano"),
}


def make_config(provider_name: str) -> Config:
    """Build a Config for the given provider with safe defaults."""
    pc = PROVIDERS[provider_name]
    return Config(provider=pc.provider, model=pc.model)


async def analyze_document(
    file_path: str,
    prompt: str,
    *,
    provider_name: str = "gemini",
) -> DocumentSummary:
    """Analyze a document — works with any supported provider."""
    config = make_config(provider_name)
    options = Options(response_schema=DocumentSummary)

    result = await run(
        prompt,
        source=Source.from_file(file_path),
        config=config,
        options=options,
    )
    return result["structured"][0]


async def main() -> None:
    prompt = "Summarize this document with key points and a word count estimate."

    # Same function, different providers
    for provider in ["gemini", "openai", "anthropic", "openrouter"]:
        try:
            summary = await analyze_document(
                "report.pdf", prompt, provider_name=provider,
            )
            print(f"[{provider}] {summary.title}: {len(summary.key_points)} points")
        except (ConfigurationError, APIError) as exc:
            print(f"[{provider}] Skipped: {exc.hint}")


asyncio.run(main())

Step-by-Step Walkthrough

  1. Centralize provider details. ProviderConfig maps each provider to its model. Your analysis functions never reference provider names or models directly.

  2. Handle features conditionally. Explicit caching (create_cache()) is Gemini-specific, while implicit caching (implicit_caching=True) is Anthropic-specific. Some providers also cache repeated prefixes automatically. Gate these conditional optimizations on the provider name or handle them near the call site; see Reducing Costs with Context Caching. The same rule applies to source helpers like Source.with_gemini_video_settings(...): keep them behind a Gemini branch instead of trying to invent a fake cross-provider equivalent.

  3. Write provider-agnostic functions. analyze_document accepts a provider name and builds the config internally. The prompt, source, and schema are the same regardless of provider.

  4. Handle portability failures at the edge. The main function catches ConfigurationError for Pollux-level unsupported features and APIError for provider-side model mismatches. This is the right place for provider-specific fallback logic.

Model-Specific Constraints

Pollux provides a unified interface, but the models underneath have their own constraints. Some reject tuning parameters. Others introduce features that don't map to traditional controls.

GPT-5 Family Rejects Sampling Controls

OpenAI's gpt-5 family (gpt-5, gpt-5-mini, gpt-5-nano) rejects temperature and top_p sampling controls with a provider error. Older models (like gpt-4.1-nano) still accept them.

# This will fail with an APIError for gpt-5 family models
options = Options(temperature=0.8, top_p=0.9)

# Instead, use the default behavior or rely on reasoning_effort
options = Options(reasoning_effort="medium")

Choosing a Reasoning Control

Pollux exposes two reasoning knobs, and they are mutually exclusive:

  • reasoning_effort takes named levels ("low", "medium", "high", and provider-specific extras).
  • reasoning_budget_tokens takes an explicit integer ceiling.

Pick reasoning_effort when you want the most portable reasoning control. Pick reasoning_budget_tokens when you need an exact provider-native token ceiling. Pollux rejects the option up front on providers that never support it, but model-specific acceptance and minimums remain provider-defined.

# Name an effort level on a provider that accepts one.
result = await run(
    "Solve this step by step...",
    config=Config(provider="gemini", model="gemini-3-flash-preview"),
    options=Options(reasoning_effort="high"),
)

# Use an explicit budget on a provider that accepts token-based reasoning.
result = await run(
    "Think briefly, then answer.",
    config=Config(provider="anthropic", model="claude-haiku-4-5"),
    options=Options(reasoning_budget_tokens=2048),
)

if "reasoning" in result:
    for text in result["reasoning"]:
        if text:
            print("Thinking:", text)

Exact budget floors are provider-defined. Pollux validates the shape (int >= 0) and provider-level support; the upstream provider may still reject a model-specific value at call time.

Reasoning Control Mapping

Provider Model Family reasoning_effort reasoning_budget_tokens Provider Behavior
OpenAI provider-supported reasoning models "low", "medium", "high" Returns a reasoning summary
Gemini reasoning-capable Gemini models provider-defined Returns full thinking text
Anthropic provider-supported reasoning models "low", "medium", "high", "max" (Opus 4.6 only) Returns thinking text and preserves replay blocks for tool loops
OpenRouter reasoning-capable models Model-dependent Returns reasoning text

Variations

Provider-specific model selection

Different providers have different model tiers. Map your quality/cost/speed preferences to provider-specific models:

MODEL_TIERS = {
    "fast": {"gemini": "gemini-2.5-flash-lite", "openai": "gpt-5-nano", "anthropic": "claude-haiku-4-5"},
    "balanced": {"gemini": "gemini-2.5-flash", "openai": "gpt-5-mini", "anthropic": "claude-sonnet-4-6"},
    "quality": {"gemini": "gemini-2.5-pro", "openai": "gpt-5", "anthropic": "claude-opus-4-6"},
}


def make_config_tiered(provider: str, tier: str = "fast") -> Config:
    model = MODEL_TIERS[tier][provider]
    return Config(provider=provider, model=model)

Graceful degradation for optional features

Some features work on one provider but not another. Wrap them in try/except at the call site rather than avoiding them entirely:

async def analyze_with_reasoning(
    file_path: str, prompt: str, *, provider_name: str,
) -> tuple[str, str | None]:
    """Analyze with reasoning when supported; fall back to plain analysis."""
    config = make_config(provider_name)

    try:
        result = await run(
            prompt,
            source=Source.from_file(file_path),
            config=config,
            options=Options(reasoning_effort="high"),
        )
        reasoning = None
        if "reasoning" in result and result["reasoning"][0]:
            reasoning = result["reasoning"][0]
        return result["answers"][0], reasoning

    except (ConfigurationError, APIError):
        # Provider or model doesn't support this reasoning mode — retry without it
        result = await run(
            prompt,
            source=Source.from_file(file_path),
            config=config,
        )
        return result["answers"][0], None

Running against a self-hosted model

Point Pollux at a locally hosted model for iteration or privacy-sensitive work, then swap back to a cloud provider for production. The pipeline code does not change:

LOCAL = Config(
    provider="local",
    model="gemma3:4b",
    base_url="http://localhost:11434/v1",
)
CLOUD = Config(provider="gemini", model="gemini-2.5-flash-lite")

result = await run(
    "Summarize this.",
    source=Source.from_text("Test content."),
    config=LOCAL,  # swap with CLOUD to go remote
)

The local provider's surface is deliberately narrow: text in, text or JSON out. Pollux passively surfaces model-native reasoning text when a local server returns it, but it does not send portable reasoning controls to local servers. If your pipeline uses file inputs, URLs, tool calling, reasoning controls, or context caching, keep those on a cloud provider and treat local as a fallback for the text-only subset. See Provider Capabilities for the full matrix.

Testing portability

Use mock mode to validate pipeline logic without API calls, then test each provider in CI with real credentials:

import pytest

@pytest.mark.parametrize("provider", ["gemini", "openai", "anthropic", "openrouter"])
async def test_analyze_document_mock(provider: str) -> None:
    config = Config(provider=provider, model="any-model", use_mock=True)
    result = await run(
        "Summarize this.",
        source=Source.from_text("Test content."),
        config=config,
    )
    assert result["status"] == "ok"
    assert len(result["answers"]) == 1

What to Watch For

  • Keep the portable subset in mind. Text generation and conversation continuity work on all providers. Structured output and tool calling are portable across Gemini, OpenAI, and Anthropic; OpenRouter supports them only on models that advertise the required parameters. Reasoning controls are also model-dependent on OpenRouter. Context caching has different paradigms (explicit for Gemini, implicit for Anthropic). YouTube URLs have limited OpenAI and Anthropic support. Check Provider Capabilities.
  • Config and provider errors are your portability signal. A ConfigurationError marks a Pollux-level unsupported feature. An APIError often means the provider rejected a model-specific combination. Handle both at the call site when you intentionally probe provider-specific features.
  • Model names are provider-specific. Never hardcode model names in your pipeline logic. Keep them in config or a lookup table.
  • Sampling controls vary. OpenAI GPT-5 family models reject temperature and top_p. If you use these, guard them with a provider check or catch the error.
  • Test with mock first. use_mock=True validates your pipeline structure without API calls. All providers return synthetic responses in mock mode.
  • Local is text-only by design. provider="local" targets self-hosted OpenAI-compatible servers for the text subset of Pollux. It can surface model-native reasoning output, but it does not send reasoning controls. If your pipeline relies on file inputs, URLs, tools, reasoning controls, or context caching, keep those paths on a cloud provider and treat local as a fallback.

For handling the errors that portability checks surface (ConfigurationError, APIError), see Handling Errors and Recovery.