Skip to content

Lesson 3 — the first call

What you will build

A question, a system prompt, and an answer you can read — plus the three facts that come back with every response and that a real application needs: which model answered, how many tokens it cost, and how many calls were made.

What you need

Nothing new to run this lesson. To point the same code at a local model, install the adapter's extra:

python -m pip install "xyberos[ollama]"
python -m pip install 'xyberos[ollama]'

The lesson itself uses the deterministic provider, so the transcript below is the same on a laptop, in CI, and in a container with no network.

examples/learn_ai/ai03_first_call.py
"""Lesson 3 — the first model call, and what comes back with it.

Run it with:

    python -m learn_ai.ai03_first_call
"""

from __future__ import annotations

import asyncio

from xyberos.ai import AIFacade, ChatRequest, FakeModelProvider, Message, ModelReply, Usage
from xyberos.errors import AIConfigurationError

SYSTEM_PROMPT = "You are a concise support assistant. Answer in one sentence."
DEFAULT_REPLY = "Take an umbrella: rain is likely this afternoon."


def choose_provider(
    profile: str,
    *,
    model: str = "llama3.2",
    base_url: str = "http://127.0.0.1:11434",
) -> object:
    """Return the provider a profile asks for.

    The local adapter is imported inside the branch that uses it, so a deployment that never selects
    ``ollama`` does not need the extra installed.
    """
    if profile == "fake":
        return FakeModelProvider([ModelReply.ok(DEFAULT_REPLY, usage=Usage(18, 9))])
    if profile == "ollama":
        from xyberos.ai.ollama import OllamaProvider, OllamaSettings

        return OllamaProvider(OllamaSettings(model=model, base_url=base_url))
    raise AIConfigurationError(f"unknown profile {profile!r}; use 'fake' or 'ollama'")


async def ask(assistant: AIFacade) -> None:
    """Ask one question and print every part of the answer a caller may need."""
    request = ChatRequest(messages=(Message.system(SYSTEM_PROMPT), Message.user("Will it rain?")))
    response = await assistant.chat(request)

    print("reply: ", response.message.content)
    print("model: ", response.model)
    print("tokens:", response.usage.total_tokens, f"(in {response.usage.input_tokens})")


def main() -> None:
    """Run the lesson against the deterministic provider, from the profile the composition chose."""
    provider = choose_provider("fake")
    print("profile: fake - pass 'ollama' to choose_provider to use a local model instead")
    asyncio.run(ask(AIFacade(provider)))
    assert isinstance(provider, FakeModelProvider)
    print("calls: ", provider.calls)


if __name__ == "__main__":
    main()

Message.system(...) and Message.user(...) are the two roles this request uses; a conversation is a tuple of messages, and the facade does not keep it anywhere. Context is something the caller assembles, which is lesson 7's subject.

How to run it

$env:PYTHONPATH = "src;examples"
python -m learn_ai.ai03_first_call
PYTHONPATH=src:examples python -m learn_ai.ai03_first_call

What it prints

profile: fake - pass 'ollama' to choose_provider to use a local model instead
reply:  Take an umbrella: rain is likely this afternoon.
model:  fake-model
tokens: 27 (in 18)
calls:  1
Line What it establishes
reply: the model's text, as response.message.content
model: which model answered — the field to log when a provider serves several
tokens: 27 (in 18) usage as the provider reported it: the input/output split is what a cost estimate needs
calls: 1 one request, one provider call — the counter a test uses to prove caching or batching

Running against a real model

The provider is chosen in one place, so pointing this lesson at a local model is one branch rather than a rewrite: choose_provider in the module above returns OllamaProvider(OllamaSettings(model=...)) for profile="ollama", and imports that adapter only when it is asked for. It needs the ollama extra and an already-pulled model, which is why the live walkthrough is an appendix rather than a lesson: checked at command level and run by an opt-in workflow, not by every build.

Common mistakes

  • Assuming the reply is JSON. It is text unless a schema was requested (lesson 5); parsing it and hoping is how a prompt change becomes an outage.
  • Ignoring usage. A call whose cost nobody records is a bill nobody can explain; the fields exist on every response for that reason.
  • Building the conversation in the module. Messages belong to the request, and the caller owns their lifecycle — a global list of messages is a shared-state bug in waiting.
  • Putting secrets in the request. A prompt sent to a provider leaves your process; the logging policy (lesson 17) governs what is recorded, not what is sent.
  • Retrying a refusal. Lesson 6 draws the line, and a refusal is on the wrong side of it.

Next

Lesson 4 shows why the seam matters: the same route, two providers, and no change to the handler.