llm

One call shape for each model: ask(prompt, sp) -> str.

This module connects the tutor to a model. The tutor needs only one function, ask(prompt, sp), which returns text. The module also parses JSON replies and gives Fake, a model for tests that runs offline.

Import the tools that this module uses.

The model

Each surface gives its own ask.

surface ask
web app, notebook rishi_ask: a new Chat of the model layer for each call, on $ABHYASA_MODEL (default claude/sonnet)
the ramabana terminal agent agent.oneshot, so the routing of the agent applies
tests Fake

source

rishi_ask

def rishi_ask(
    model:str=None
):

An ask(prompt, sp='') on a rishi.Chat (the model layer) for model, else $ABHYASA_MODEL, else DFLT_MODEL.

JSON replies

parse_json finds the JSON in a reply. ask_json asks again one time when the reply is not valid JSON.


source

ask_json

def ask_json(
    ask, prompt:str, sp:str='', tries:int=2
):

Ask for JSON and parse it. On a bad reply, ask one more time with the parse error.


source

parse_json

def parse_json(
    s:str
):

The first JSON object or array in s, also inside a ``fence. RaiseValueError` when there is none.

parse_json reads JSON in a fence, JSON inside other text, and arrays. A fence inside a JSON string does not break it. It fails when there is no JSON.

test_eq(parse_json('```json\n{"a": 1}\n```'), {'a': 1})
test_eq(parse_json('Here it is: {"a": [1, 2]} thanks'), {'a': [1, 2]})
test_eq(parse_json('[{"x": 1}]'), [{'x': 1}])
test_fail(lambda: parse_json('nothing'), contains='no JSON')
test_eq(parse_json('{"answer": "Use ```python\\nx = 1\\n``` here."}'), {'answer': 'Use ```python\nx = 1\n``` here.'})
test_eq(parse_json('Sure. {"a": 1} and then {"b": 2}'), {'a': 1})

A model for tests

Fake replies from a dict of rules. The first rule with a key in the prompt gives the reply. It sends a dict or a list reply as JSON. It keeps each call in calls.


source

Fake

def Fake(
    rules:dict=None, dflt:str='{}'
):

A stand-in model for tests: Fake({'grade': {...}, 'question': {...}}).

A matching rule gives its reply, and Fake records the call.

f = Fake({'GRADE': dict(correct=True)})
test_eq(ask_json(f, 'GRADE this'), dict(correct=True))
test_eq(len(f.calls), 1)

After a reply that is not JSON, ask_json asks again and gets the JSON.

bad = Fake(dict(X=lambda p, sp: 'oops' if 'not valid' not in p else '{"ok": 1}'))
test_eq(ask_json(bad, 'X'), {'ok': 1})

A model for each task

The tutor does five tasks, and each task can use its own model. A quick model can grade and give hints. A strong model can write questions and lessons. The Settings page saves the choice. Router reads it on each call, so a change has an effect on the next call.

task what the model does
question writes a question and its hidden model answer
grade reads the conversation and grades it
hint gives the next hint
lesson teaches the smallest step after a miss
extract finds the concepts in a text

model_ask gives an ask for any model name. Install the models extra (pip install abhyasa[models]) to use the full list of models. With the extra, the name can be any model in that list: Claude Code, Codex, OpenAI, Copilot, Gemini, or a model on this machine (LiteRT, mlx, llama.cpp, Ollama). The extra finds the model for the name and gives a backend of the model layer. Without the extra, the name goes to the model layer as it is. Each model has one lock, so the calls take turns. A backend from the full list refuses a second call while one call runs.


source

model_ask

def model_ask(
    name:str
):

An ask(prompt, sp='') for model name, made once and kept.

Names for the model list

The Settings page shows a list of models. Each item has a name that people know, such as “Claude Sonnet” or “GPT-5.5”. The model id, such as claude/sonnet, is the value that the settings keep. model_label makes the name from the id. PROVIDERS gives the name of each group in the list.


source

model_label

def model_label(
    value:str, provider:str='', repo:str=''
)->str:

A name that people know for the model id value; repo is the download of a local model, if known.

Each kind of id gets a plain name. A Claude Code tier gets the word Claude. A local model says so.

test_eq(model_label('claude/sonnet'), 'Claude Sonnet')
test_eq(model_label('claude/claude-opus-5-5'), 'Claude Opus 5.5')
test_eq(model_label('anthropic/claude-haiku-4-5-20251001'), 'Claude Haiku 4.5')
test_eq(model_label('codex/gpt-6-astra'), 'GPT-6 Astra')
test_eq(model_label('openai/gpt-5.4-mini'), 'GPT-5.4 Mini')
test_eq(model_label('gemini/gemini-3.5-flash'), 'Gemini 3.5 Flash')
test_eq(model_label('qwen-4b', 'mlx', 'mlx-community/Qwen3.5-4B-MLX-4bit'), 'a local model: Qwen 3.5 4B')
test_eq(model_label('gemma-e4b', 'litert', 'litert-community/gemma-4-E4B-it-litert-lm'), 'a local model: Gemma 4 E4B')
test_eq(model_label('ollama/qwen3:8b'), 'a local model: Qwen 3 8B')
test_eq(model_label('my-model', 'azure'), 'my-model')

catalog lists the models that can run here, for the Settings page. With the models extra, the list is the full list of models. Each row gets a name from model_label. The list stays in memory for five minutes, because the look-up asks each provider. Without the extra, the list has only the models in FALLBACK.


source

catalog

def catalog(
    ttl:float=300.0
)->list:

The models that can run here, as dicts with value, name, label, provider and source.

Without the models extra, the list has the three Claude tiers, each with a name.

_cat.clear()
try: import ramabana; has_rama = True
except ImportError: has_rama = False
if not has_rama: test_eq([r['name'] for r in catalog()], ['Claude Sonnet', 'Claude Haiku', 'Claude Opus'])
assert all(r['name'] for r in catalog())

Router is the ask the app gives the tutor. Called as it is, it uses the default model. for_(task) gives the ask for one task: the model the settings name for it, else the default.


source

for_task

def for_task(
    ask, task:str
):

The ask for task when ask routes by task, else ask itself.


source

Router

def Router(
    dflt, models:NoneType=None
):

The tutor’s model: a default ask, and one model for each task that the settings name.

A router with no settings uses the default for every task. A task that names a model gets that model.

dflt = Fake(dflt='default')
r = Router(dflt, models=lambda: {})
test_eq(r('x'), 'default')
test_is(r.for_('grade'), dflt)
named = Fake(dflt='named'); named.model = 'fast'
_asks['fast'] = named
r = Router(dflt, models=lambda: dict(grade='fast'))
test_eq(r.for_('grade')('x'), 'named')
test_eq(r.for_('question')('x'), 'default')
test_is(for_task(dflt, 'grade'), dflt)