Choosing a Model

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Examples on this page target Python 3.12 with the current anthropic Python SDK, calling the hosted models claude-haiku-4-5, claude-sonnet-5, claude-opus-5-5 and claude-fable-5-1, and reference OpenAI’s current flagship line — gpt-6-luna, gpt-6-sol and gpt-6-astra — for the equivalent build-vs-buy comparison. The Model Landscape covers what each of these families and tiers actually is; this page is only about the process of picking one for a task. Model names, IDs and the guidance below were checked against Anthropic’s own "Choosing a model" guide and OpenAI’s models page as of 2026-09-28.

The selection workflow

Picking a model is rarely a one-shot decision — it is a short loop that narrows a large field of candidates down to one, revisited whenever requirements change or a new model ships:

  1. Requirements. Write down what the task actually needs before looking at any model: required capabilities (tool use, vision, long context, extended reasoning), a latency ceiling the product can tolerate, a per-request or per-month cost ceiling, and any constraint that rules out a hosted API outright (data residency, an air-gapped deployment).

  2. Shortlist. Narrow the full landscape from The Model Landscape down to two to four candidates that plausibly meet the requirements above — typically one frontier-tier model as a capability ceiling, one fast/small-tier model as a cost floor, and, when the on-device or air-gapped constraint applies, one open-weight model that can run locally.

  3. Evaluate on your own data. Public benchmarks (see Benchmarks & Leaderboards) tell you how a model does on someone else’s task; they do not tell you how it does on yours. Run each shortlisted model against a small, representative set of your own inputs and expected outputs, and score them the same way you will judge the model in production. The example below builds a minimal harness for exactly this step.

  4. Cost/latency check. A model that passes the quality bar still has to fit the budget established in step 1. Cost, Latency & Throughput covers the TTFT/TPOT latency formula and token-pricing math this check is built on; this page only asks the question, that one answers it.

  5. Decide. Ship the smallest, cheapest model that clears both the quality bar from step 3 and the budget from step 4 — not the most capable model available. If nothing on the shortlist clears both bars at once, routing and cascades (below) are usually a better next move than simply picking the most expensive candidate.

The loop these five steps form, including where it branches back on a failed check, is shown below:

flowchart TD requirements["1. Requirements
capabilities, latency ceiling, cost ceiling, constraints"] shortlist["2. Shortlist 2-4 candidate models"] eval["3. Evaluate on your own data"] quality{"Meets quality bar?"} budget{"Within cost / latency budget?"} decide["5. Decide: ship this model"] cascade["Reconsider: smaller model, or a cascade/router"] requirements --> shortlist --> eval --> quality quality -- "no" --> shortlist quality -- "yes" --> budget budget -- "no" --> cascade --> eval budget -- "yes" --> decide

Build vs. buy / open vs. proprietary

The same workflow applies whether the candidates are hosted proprietary APIs, open-weight models you self-host, or a mix of both — but the criteria that tip the decision differ by dimension:

Criterion Proprietary / hosted API Open-weight / self-hosted

Privacy

Request and response data leaves your infrastructure and is governed by the vendor’s data-handling terms (retention, training-data use, region).

Data never has to leave infrastructure you control, which matters most under strict regulatory or contractual data-residency constraints.

Lineage

Training data and fine-tuning process are the vendor’s own and are not published in detail; you are trusting the vendor’s published usage policies and model card.

Weights, and often the training recipe and data sources, are published under a license you can inspect yourself, which matters for provenance and license-compliance audits.

Control

No control over model updates, deprecation schedule or underlying infrastructure; the vendor can change or retire a model version on its own timeline.

Full control over which exact weights run, when they change, and how the serving stack (batching, quantization) is configured.

Cost

Pay-per-token pricing with no infrastructure to operate, which is usually cheaper at low-to-moderate volume and avoids the fixed cost of GPU capacity.

Requires provisioning and operating inference infrastructure (or renting it), which can be cheaper at very high, sustained volume but carries real fixed and operational cost.

Performance

Frontier-tier proprietary models are usually the strongest available on general capability and the first to get new features (extended thinking, native tool use, long context).

The strongest open-weight models trail frontier proprietary models on many benchmarks, but the gap narrows quickly and a smaller open-weight model can still beat a hosted model on a narrow, well-specified task.

On-device

Not applicable — inference always happens on the vendor’s infrastructure, reachable only over the network.

The only option that can run fully on-device or air-gapped, at the cost of the on-device hardware’s own memory and compute ceiling; The Model Landscape covers the on-device tier in more depth.

None of these rows names a winner in the abstract — a regulated, high-volume, latency-sensitive workload leans open-weight/self-hosted on nearly every row, while a low-volume prototype that needs the strongest available reasoning leans proprietary on nearly every row, and most real systems land somewhere in between.

Routing and cascades

A single model rarely has to answer every request in a system. Routing sends each request to whichever model already fits it best (a cheap model for a simple lookup, a frontier model for a hard multi-step task); a cascade goes one step further and lets a cheap model attempt every request first, escalating to a stronger model only for the subset it could not handle confidently. Chen et al.'s FrugalGPT paper formalizes this as an LLM cascade: querying models in increasing order of cost and stopping as soon as one response is judged adequate, which the paper shows can cut cost substantially while matching a single frontier model’s accuracy. Anthropic’s own guidance describes the same idea as an "executor escalates to an advisor" pattern, and an "orchestrator delegates to lower-cost workers" pattern, both aimed at billing most tokens at the cheaper model’s rate. See Anthropic — Optimizing for cost and intelligence.

The cascade example below implements the escalation half of this idea directly: try claude-haiku-4-5 first, and fall back to claude-sonnet-5 whenever the fast model’s answer is either invalid JSON or reports low confidence in its own extraction.

Mixing models per task

Routing and cascades pick a model per request; mixing models per task picks a model per stage of a pipeline, ahead of time, because different stages have genuinely different requirements. A DocsAssistant pipeline, for example, might use a small, fast model to classify an incoming question’s topic and decide which retriever to call, a mid-tier model to draft the grounded answer from retrieved passages, and reserve a frontier model only for the minority of questions that need multi-step reasoning across several retrieved pages. Each stage is evaluated and priced independently, using the same workflow above, rather than forcing one model to be the best choice for every stage at once.

Example: a tiny eval harness across two models

The harness below reuses the \{title, component, topics} extraction shape from Structured Output & Tool Calling, runs the same five DocsAssistant questions against two candidate models, and scores each model’s answer by exact-match on every expected JSON field:

import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment

EXTRACT_PAGE_METADATA = {
    "name": "extract_page_metadata",
    "description": "Extract structured metadata from a DocsAssistant page.",
    "strict": True,
    "input_schema": {
        "type": "object",
        "properties": {
            "title": {"type": "string"},
            "component": {"type": "string"},
            "topics": {"type": "array", "items": {"type": "string"}},
        },
        "required": ["title", "component", "topics"],
        "additionalProperties": False,
    },
}

# Five DocsAssistant pages, each with the metadata a correct extraction should return.
EVAL_CASES = [
    {
        "page_text": "== Configuring HNSW parameters\nThis page (component: pgvector-guide) covers "
                     "ef_search, ef_construction and m for pgvector HNSW indexes.",
        "expected": {
            "title": "Configuring HNSW parameters",
            "component": "pgvector-guide",
            "topics": ["ef_search", "ef_construction", "m"],
        },
    },
    {
        "page_text": "== Prompt caching with cache_control\nThis page (component: llm-foundations) covers "
                     "ephemeral cache breakpoints and the 5-minute and 1-hour TTLs.",
        "expected": {
            "title": "Prompt caching with cache_control",
            "component": "llm-foundations",
            "topics": ["ephemeral cache breakpoints", "5-minute TTL", "1-hour TTL"],
        },
    },
    {
        "page_text": "== Retrieval Augmented Generation basics\nThis page (component: vector-rag) covers "
                     "chunking, retrieval and prompt augmentation.",
        "expected": {
            "title": "Retrieval Augmented Generation basics",
            "component": "vector-rag",
            "topics": ["chunking", "retrieval", "prompt augmentation"],
        },
    },
    {
        "page_text": "== Spring AI ChatClient basics\nThis page (component: spring-ai) covers prompt(), "
                     "call() and entity().",
        "expected": {
            "title": "Spring AI ChatClient basics",
            "component": "spring-ai",
            "topics": ["prompt()", "call()", "entity()"],
        },
    },
    {
        "page_text": "== Sampling with temperature and top-p\nThis page (component: llm-foundations) covers "
                     "temperature, top-p and top-k.",
        "expected": {
            "title": "Sampling with temperature and top-p",
            "component": "llm-foundations",
            "topics": ["temperature", "top-p", "top-k"],
        },
    },
]


def extract(model: str, page_text: str) -> dict:
    response = client.messages.create(
        model=model,
        max_tokens=1024,
        tools=[EXTRACT_PAGE_METADATA],
        tool_choice={"type": "tool", "name": "extract_page_metadata"},
        messages=[{"role": "user", "content": f"Extract metadata from this page:\n\n{page_text}"}],
    )
    tool_use = next(block for block in response.content if block.type == "tool_use")
    return tool_use.input


def exact_match_score(actual: dict, expected: dict) -> float:
    """Fraction of the expected fields the actual answer matched exactly."""
    fields = expected.keys()
    matches = sum(1 for field in fields if actual.get(field) == expected[field])
    return matches / len(fields)


def run_eval(models: list[str]) -> dict[str, float]:
    scores = {model: 0.0 for model in models}
    for case in EVAL_CASES:
        for model in models:
            actual = extract(model, case["page_text"])
            scores[model] += exact_match_score(actual, case["expected"])
    return {model: total / len(EVAL_CASES) for model, total in scores.items()}


average_scores = run_eval(["claude-haiku-4-5", "claude-sonnet-5"])
print(average_scores)  # e.g. {'claude-haiku-4-5': 0.87, 'claude-sonnet-5': 1.0}

Field-level exact match (rather than whole-object equality) is deliberate: it still fails a near-miss, but it also shows which field a weaker model gets wrong most often — often topics, since it is the only field with more than one valid-looking answer — which is exactly the signal step 3 of the workflow above needs. See Anthropic — Strict tool use for the strict: true extraction this harness relies on.

Example: a cascade router

Unlike the harness above, a cascade needs to detect a bad answer at request time, not just score it after the fact — so this example deliberately asks the fast model for plain JSON text (including a self-reported confidence field) instead of a strict tool call, and treats a JSON-parse failure or a low confidence value as the signal to escalate to the stronger model:

import json
import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY from the environment

FAST_MODEL = "claude-haiku-4-5"
STRONG_MODEL = "claude-sonnet-5"
CONFIDENCE_THRESHOLD = 0.7

PROMPT_TEMPLATE = (
    "Extract title, component and topics as JSON from this DocsAssistant page. Include a "
    '"confidence" field between 0 and 1 for how sure you are of the extraction. Return JSON '
    "only, no other text.\n\n{page_text}"
)


def parse_json_reply(text: str) -> dict | None:
    """Parse a JSON reply, tolerating a Markdown code fence (```json ... ```) around it."""
    text = text.strip()
    if text.startswith("```"):
        text = text.split("\n", 1)[1] if "\n" in text else ""
        text = text.rsplit("```", 1)[0]
    try:
        parsed = json.loads(text)
    except json.JSONDecodeError:
        return None
    return parsed if isinstance(parsed, dict) else None


def call_model(model: str, page_text: str) -> dict | None:
    response = client.messages.create(
        model=model,
        max_tokens=1024,
        messages=[{"role": "user", "content": PROMPT_TEMPLATE.format(page_text=page_text)}],
    )
    return parse_json_reply(response.content[0].text)  # None = failed attempt, see below


def extract_with_cascade(page_text: str) -> dict:
    result = call_model(FAST_MODEL, page_text)
    needs_escalation = result is None or result.get("confidence", 0) < CONFIDENCE_THRESHOLD
    if needs_escalation:
        result = call_model(STRONG_MODEL, page_text)
    if result is None:
        # Even the strong model returned unparseable text: fail loudly instead of handing None
        # to the caller (or fall back to the strict tool call used by the harness above).
        raise ValueError("Both models failed to return valid JSON for this page.")
    return result

Models often wrap a "JSON only" reply in a Markdown code fence anyway, so parse_json_reply strips one before parsing — otherwise every fenced reply would count as a failure and escalate. If the strong model’s reply is still unparseable, the cascade raises rather than returning None from a function typed → dict. extract_with_cascade bills every request at FAST_MODEL’s rate except the ones that come back invalid or underconfident, which is the same cost/quality trade FrugalGPT’s cascade formalizes — only here the escalation condition is a self-reported confidence score rather than a learned scoring function. A production cascade usually replaces the model’s self-reported `confidence with an external check (a schema validator, a downstream consumer’s own confidence signal, or a cheaper judge model) once self-reported confidence turns out to be poorly calibrated for the task at hand. See Chen et al. — FrugalGPT: How to Use Large Language Models While Reducing Cost and Improving Accuracy.