env
def env(
name, dflt:NoneType=None
):Read $<prefix><name>, then $<fallback><name>, else dflt.
Ramabana reports a failure in one of two ways. AgentError is a refusal: something the harness will not do, with a message the user can act on, such as a write outside the open folders or a model whose key is missing. It is shalya’s HostError under another name, so a refusal from a tool and a refusal from the agent are caught the same way. Any other exception is a bug, and is left to look like one.
BranchChanged is the refusal raised when a git branch moved while a person was deciding whether to approve a write to it. Nothing was written.
Read $<prefix><name>, then $<fallback><name>, else dflt.
Set the env var prefixes this application reads, most specific first.
Render a caught exception for a user-facing surface, keeping its type; a ContextFull is its message alone.
A turn that does not fit the model’s window after compaction and trimming; its message is the whole report.
A branch moved while a person decided on it, so nothing was written.
agent_err turns a caught exception into one line for a person to read. It keeps the exception’s type name in front, because the bare message nbs/99_missing.ipynb does not say what went wrong:
env reads one setting from the environment. Every setting has two names, because Ramabana also runs inside Leela: env('MODEL') reads $RAMABANA_MODEL, then $LEELA_MODEL, then returns the default you pass. An application built on Ramabana calls use_env_prefix once at startup to have its own prefix read first.
MODELS is the table of short model names you can type, such as sonnet or gemma-e4b. Each name maps to a pair: the runtime that serves it, and the id that runtime knows the model by. It is built from smaller tables. LOCAL holds the LiteRT models, MLX the models for Apple silicon, LLAMA the llama.cpp models, CLOUD the OpenAI and Codex models, and the Claude tables come from the catalog in models.
JOBS lists every job the policy can route on its own: the turn, one-shot prompts, inline edits, code completion, classification, summaries, and sub-agents. ONESHOT_JOBS are the jobs that send one prompt and read one answer back.
claude_ctx gives a Claude model’s context window, the number of tokens it can read at once. claude_wire gives the id to ask Claude Code for, which carries a [1m] suffix for the models that have a million-token window.
The id Claude Code is asked for: its plain id serves 200k, and the 1M window is <id>[1m].
A Claude Code model’s context window, else DFLT_AGENT_CTX.
Some questions are about this machine: whether a runtime is installed, which models an API key can list, which models a Copilot plan reaches. Each answer takes too long for a model picker to wait on, and changes rarely enough that the last answer is usually still right. probed(key, fn) remembers these answers. Use it for any slow question whose last answer is good enough to show.
fn() and returns its answer.ttl seconds, a background thread asks again, and a later call gets the new answer.~/.config/ramabana/probes, so the next run of the program has a value on its first call. Pass disk=False to keep it in memory only.forget_probes drops every remembered answer, for a refresh button. forget_probes(disk=True) deletes the files too. The hidden test below walks through each case.
Drop every cached answer, and with disk its file too.
fn()’s last answer, refreshing a stale one in the background.
Where one probe’s last answer is kept between runs.
Why a harness runtime cannot be reached, or '' when it can.
One sentence saying what to do about a runtime that cannot be reached here.
Whether runtime can run models on this machine; never raises.
The core install is the agent, its hosts, and the file, code, shell and git tools. Each extra, such as ramabana[search], adds the packages for one more use. installed says whether a module would import, without importing it. need(extra, *mods) returns an empty string when the packages are present, and otherwise the sentence telling the user what to install, so a command that needs an extra can refuse with something useful. Both live in setup, which the console scripts import first because it loads nothing heavy, and are re-exported here.
The console scripts, for a ramabana script installed while they lived here.
Back to the model table. Each short name resolves to a runtime and a model id:
{'gemma-e4b': ('litert', 'litert-community/gemma-4-E4B-it-litert-lm'),
'gpt-4.1-mini': ('remote', 'openai/gpt-4.1-mini'),
'claude-sonnet-5': ('claude', 'claude-sonnet-5'),
'sonnet': ('claude', 'claude-sonnet-5')}
auth_status reports which credentials this machine has, one entry per vendor: an OpenAI, Anthropic or Gemini key in the environment, a Codex login, a Claude Code login, a Copilot sign-in, and Azure OpenAI. It never returns, logs or stores a secret. For Claude Code it runs claude auth status rather than reading the credential file.
Which credential sources this machine has, per vendor, without reading or returning any secret.
Whether spec goes to Azure OpenAI: named azure/..., or its base_url is an Azure host.
Whether Azure OpenAI has an endpoint and its key is in the environment.
The env var holding the Azure OpenAI key: config.toml’s azure.api_key_env, else AZURE_KEY_ENV.
The v1 base of $AZURE_OPENAI_ENDPOINT, else of config.toml’s azure.endpoint, else ’’.
Azure OpenAI’s v1 API base for a resource URL, however much of a path or query it carries; ’’ for none.
Every vendor appears whether or not it is connected, with the same two fields, so a settings page can draw the whole list without special cases:
{'openai': True,
'codex': True,
'anthropic': False,
'claude': True,
'gemini': True,
'copilot': True}
Azure OpenAI needs AZURE_OPENAI_ENDPOINT, the resource such as https://NAME.openai.azure.com, and AZURE_OPENAI_API_KEY. config.toml’s [azure] table can hold the endpoint instead, as endpoint, and name another env var for the key with api_key_env; the key itself never goes in the file. Setting them chooses nothing: a deployment is used only where you name it, as azure/DEPLOYMENT or in a [model."NAME"] table. azure_base turns whatever form of the endpoint was pasted into the v1 base, dropping a deployment path and an api-version; the v1 API needs neither. azure_ready says whether the endpoint and the key are both there, and is_azure whether a spec goes to Azure.
for u in ('https://r.openai.azure.com', 'r.openai.azure.com/', 'https://r.openai.azure.com/openai/v1/', 'https://r.openai.azure.com/openai/deployments/x/chat/completions?api-version=2024-10-21'):
test_eq(azure_base(u), 'https://r.openai.azure.com/openai/v1')
test_eq(azure_base('https://apim.azure-api.net/team/openai'), 'https://apim.azure-api.net/team/openai/v1')
assert azure_host('https://r.openai.azure.us/openai/v1') and not azure_host('https://api.openai.com/v1')register_model adds a short name of your own to the tables for the rest of this process. Use it for a model the tables do not list. A Hugging Face URL is enough, because rishi works out which runtime serves it. unregister_model removes it.
Remove one process-local configurable model alias.
Add a model name that lasts until this process exits; save_model keeps one on disk.
A name added with register_model is gone after a restart. save_model keeps one in a file, and every application that offers its own names needs the same file, so the file is handled here:
save_model(row) checks a row, registers it, and writes it to the file.load_models() reads the file at startup, registers every row that still resolves, and skips the rest.delete_model(name) removes a name from the file and from this process.saved_models() returns the rows on disk.A row holds name, model_id, runtime and ctx. A row for the remote runtime may also name an endpoint with base_url, vendor_name and api_name, and the environment variable that holds its key with api_key_env. The file never holds a key. path names the application’s own file and defaults to ~/.config/ramabana/models.json.
Forget one alias, on disk and in this process.
Validate, register and persist one alias. Returns the row as written.
Register every saved alias that still resolves, and return those rows.
The alias rows on disk, or [] if the file is missing or malformed.
The file one application keeps its model aliases in.
Point every alias call that names no file at an application’s own, as use_env_prefix does for env vars.
config.toml can define a model as well as correct one. A [model."NAME"] table with model_id registers NAME for this process, for [jobs], --model and /model, with the same base_url, api_key_env, api_name, vendor_name and runtime a saved alias carries; ctx is another name for max_input_tokens. The runtime is remote when any of those API settings is given. On an Azure host, api_name defaults to openai, the Responses API, and api_key_env to AZURE_OPENAI_API_KEY. A table that names a built-in model, a runtime nobody knows or a runtime that is missing here is ignored with one warning, which model_problems() and Agent.problems repeat. Nothing raises.
sync_models() registers the application’s saved aliases and then config.toml’s definitions, and does it again whenever either file changes. resolve, available_models and Routing call it, so the terminal session, ramabana-mcp, ramabana-acp, a headless run and an embedding app all see the same names without a call at startup. A name defined in both places is config.toml’s: load_models skips it and save_model refuses it. An application with its own alias file points the calls that name no file at it with use_aliases(path).
remote_models() lists the hosted models defined here, in config.toml or as saved aliases, whose credentials are set, config.toml’s first. A setup page counts them; the routing never picks one by itself.
Hosted models defined here, in config.toml or as saved aliases, whose credentials are set: config.toml’s first.
Why a config.toml definition was ignored, one line each.
Register the saved aliases, then config.toml’s definitions, again whenever either changes; config.toml wins a name both define. The names registered.
config.toml’s [model."NAME"] tables that define a model, those with model_id: {name: table}.
available_models builds the list a model picker shows. Each row names a model this process can actually run. The on-device models appear when their runtime is installed, ollama’s models when its daemon answers, and a vendor’s models only when the credential for it exists. The hosted lists come from provider_models in models. copilot_catalog is the model list for your Copilot plan, which the Copilot server reports and probed remembers for five minutes. A model config.toml defines appears with source config.toml once it can run.
Models selectable here, with older generations only when include_legacy.
Copilot’s catalogue for this account, cached: {id: entry}, or {} when it cannot be reached.
Each row has a value to pass to resolve, a label to show, its provider, and the source that made it available, which the picker shows beside it. This check stands in a fake Copilot plan with one chat model:
_orig_avail, _orig_cat = _copilot_available, copilot_catalog
_copilot_available = lambda: True
copilot_catalog = lambda ttl=300: {'test-chat': {'capabilities': {'type': 'chat'}}}
try:
rows = available_models()
test_eq([r['value'] for r in rows if r['provider'] == 'litert'], list(LOCAL))
test_eq([r['value'] for r in rows if r['provider'] == 'copilot'], ['copilot/test-chat'])
finally: _copilot_available, copilot_catalog = _orig_avail, _orig_catOlder model generations, such as gpt-4.1, are left out unless you ask for them with include_legacy=True:
Every default route is a hosted model:
| job | default model | through |
|---|---|---|
turn |
claude-opus-5-5 |
Claude Code |
subagent |
claude-sonnet-5-5 |
Claude Code |
oneshot, inline, summary |
gpt-6-luna |
a Codex login |
completion, classify |
claude-haiku-5-5 |
Claude Code |
DEFAULT_POLICY leaves turn empty, so that $RAMABANA_MODEL or DFLT_TURN fills it, and leaves inline empty, so that it follows oneshot. A model on this machine runs the turn only when you name it with --model, /model or $RAMABANA_MODEL. Once you do, the other jobs follow the turn onto the machine. DFLT_LOCAL, qwen-4b, is the local model to suggest; it is never a route unless named.
When none of the hosted models for a job can run here, the job moves to DFLT_FALLBACK, Gemma 4 E4B on LiteRT, rather than failing. The turn never moves. Changing models in the middle of a conversation is your decision, not a default.
A local model’s context window is the size Ramabana uses, which can be smaller than the size it was trained with, because a larger window costs memory and time. local_ctx gives that size: $RAMABANA_LOCAL_CTX first, then config.toml’s max_input_tokens for that model and its window.local_ctx, then the table. The engine still caps it at the size the model was trained with. local_window reads the trained size from the model’s own config, for the runtimes that publish it.
The context window to use for local model name: $RAMABANA_LOCAL_CTX if it sets one, then config.toml’s [model."NAME"] and window.local_ctx, else the table.
A local model’s trained context window from its own config, else 0.
Every job has an entry in the default policy, so a new job cannot be added without deciding where it runs:
local_ctx takes the size from a table, with 32,768 tokens for a model the table does not name. $RAMABANA_LOCAL_CTX overrides it, either as one number for every model or as name:size pairs separated by commas:
(16384, 16384, 32768)
resolve(name) turns a model name into a ModelSpec: the runtime, the model id, the context window, any runtime options, and a note for the user. Every place that needs to run a model calls it, and code further down accepts the ModelSpec rather than resolving the name again. A runtime that is not installed here does not raise: the spec comes back with missing saying which binary or package is absent and how to get it, the same way a missing API key is left for Agent.start to report. missing_runtime(spec) asks the same question live, and Agent.start turns the answer into a failed turn that names a model that does run here, from available_models. resolve raises KeyError with the closest known names for a name it cannot place.
azure/DEPLOYMENT names an Azure OpenAI deployment on the v1 API of azure_endpoint(), with the key in azure_key_env(), through the Responses API. It resolves whether or not the deployment is listed. A deployment named for a model fastllm knows, such as gpt-5.4, gets that model’s window and briefing; any other name gets 128k and a note saying which [model."azure/NAME"] entry corrects it.
A hosted model’s window comes from fastllm’s table, which can be out of date. A [model."NAME"] table in config.toml corrects it on this machine: max_input_tokens replaces the window, max_output_tokens becomes the reply cap the chat is started with, and supports_images answers accepts(spec, 'image'). The route name you chose the model by wins over its model id.
spec_caps and accepts say what a resolved model can be sent, such as images or audio. A model rishi knows nothing about counts as accepting everything. model_note describes a spec in one line for a status bar.
urai’s ModelSpec, with backend and config as views of runtime and opts.
Can spec’s model be sent kind? config.toml’s supports_images answers for a picture; unknown counts as yes.
What spec’s model accepts and what it hands back, or None where rishi cannot say.
A ModelSpec for name: a short name from the tables, or any full vendor/model spec; none is DFLT_TURN. A runtime this machine lacks is named in missing, not raised.
Which model to try instead, from available_models. A suggestion: nothing is switched.
Why spec‘s runtime cannot run on this machine and how to get it;’’ when it can. Asked live, never cached.
The error message for a model name nobody knows, with the closest known names.
A short name resolves from the tables. A full vendor/model name resolves even when no table mentions it, so a model released this morning can be used this morning:
ModelSpec(name='gemma-e4b', backend='litert', model_id='litert-community/gemma-4-E4B-it-litert-lm', ctx=16384, note='', config={})
spec = resolve('claude/claude-sonnet-5')
test_eq(spec.backend, 'claude')
test_eq(spec.local, False)
assert isinstance(spec, urai.ModelSpec) and spec.runtime == spec.backend and spec.opts is spec.config
test_eq(set(RUNTIME_NAMES), set(urai.RUNTIMES)); assert 'ollama' in RUNTIME_NAMES
test_eq({r for r in RUNTIME_NAMES if ModelSpec('x', r, 'x').local}, {k for k, v in urai.RUNTIMES.items() if v.local})
assert ModelSpec('fake', 'fake', 'fake/model').local
model_note(spec)
if runtime_available('mlx'):
spec = resolve('mlx-community/a-model-nobody-tabled')
test_eq((spec.backend, spec.model_id), ('mlx', 'mlx-community/a-model-nobody-tabled'))
else:
assert 'mlx runtime is unavailable (mlx_lm is not installed)' in resolve('mlx-community/a-model-nobody-tabled').missing
test_eq(resolve('somevendor/some-model').backend, 'remote')
test_fail(lambda: resolve('gpt-image-2'), contains='unknown model')The tables list what the picker offers, not everything that runs. A Claude id the tables have moved past, such as claude-opus-5, claude-sonnet-4-6 or opus-5, still resolves to the Claude Code runtime with the id passed through. A saved session or a pinned setting keeps working, and Claude Code decides whether it still serves that id. Any other unknown name is taken as a typo, and fails where it was typed rather than in the middle of a turn.
_orig_ra = runtime_available
runtime_available = lambda rt: rt == 'claude' or _orig_ra(rt)
try:
for old in ('claude-opus-5', 'claude-sonnet-4-6', 'claude-haiku-4-5', 'claude-opus-4-8', 'claude-sonnet-5', 'claude-fable-5-1'):
spec = resolve(old)
test_eq((spec.backend, spec.model_id), ('claude', old))
test_eq(resolve('opus-5').model_id, 'claude-opus-5')
for full in ('claude/claude-opus-5', 'claude/claude-opus-4-8', 'claude/claude-haiku-4-5', 'claude/opus-5', 'claude/sonnet', 'claude/haiku', 'claude/claude-fable-5-1'):
bare = full.split('/', 1)[1]
test_eq(resolve(full).model_id, CLAUDE_ALIASES.get(bare, bare))
test_eq(resolve('opus').model_id, CLAUDE_ALIASES['opus'])
test_eq([r['label'] for r in available_models() if r['provider'] == 'claude'], list(CATALOG['claude']))
finally: runtime_available = _orig_ra
test_fail(lambda: resolve('gpt-9'), contains='unknown model')Copilot needs its own runtime. If copilot/gpt-5.5 were handed to remote, rishi would read copilot as a prefix it knows and drop it, and send the bare gpt-5.5 to OpenAI on your OpenAI key. The turn would work, and the charge would land on the wrong account. So resolve handles the copilot/ prefix itself. The Copilot model list is asked for rather than written into a table, because it depends on the plan and changes often, and each entry carries the context window Copilot allows:
_cat = {'gpt-5.5': {'capabilities': {'type': 'chat', 'limits': {'max_prompt_tokens': 272000}}},
'claude-opus-4.7': {'capabilities': {'type': 'chat', 'limits': {'max_context_window_tokens': 264000}}},
'text-embedding-3-small': {'capabilities': {'type': 'embeddings'}}}
_orig_avail, _orig_cat = _copilot_available, copilot_catalog
_copilot_available, copilot_catalog = (lambda: True), (lambda ttl=300: _cat)
try:
test_eq(runtime_available('copilot'), True)
test_eq(auth_status()['copilot']['available'], True)
spec = resolve('copilot/gpt-5.5')
test_eq(spec.runtime, 'copilot')
test_eq(spec.model_id, 'gpt-5.5')
test_eq(spec.local, False)
test_eq(spec.ctx, 272000)
test_eq(resolve('copilot/claude-opus-4.7').ctx, 264000)
assert resolve('copilot/gpt-6-unreleased').ctx > 0
rows = [r for r in available_models() if r['provider'] == 'copilot']
test_eq({r['value'] for r in rows}, {'copilot/gpt-5.5', 'copilot/claude-opus-4.7'})
assert all(r['label'] and r['source'] for r in rows)
finally: _copilot_available, copilot_catalog = _orig_avail, _orig_cat
_copilot_available = lambda: False
try: assert 'sign in to Copilot' in resolve('copilot/gpt-5.5').missing
finally: _copilot_available = _orig_availBefore a turn, the agent sends the model a briefing: the system prompt with the project’s rules, the tool schemas, and sometimes the full text of a skill. On a small context window this fills most of the space before any work starts. On a full host the tool schemas cost about 4.7k tokens, an inlined skill 3k more, and the briefing 1.4k, which leaves a 16k model room for about one tool result.
budget_for(spec, tool_max) sizes the briefing to the model’s window and returns a Budget. For a window above 24k tokens the budget changes nothing except the cap on each tool result. For a smaller window it drops the memory and web tool groups, stops inlining skill text, and clips each tool result harder. It only holds things back: a skill it does not inline is still one read_skill call away. With channel='tags' it first subtracts the tokens the tool schemas will take inside the prompt (see below).
A window-sized budget; tool_max=None selects an automatic cap up to 32000 characters.
What a model can afford to be told, and to be sent back.
The window decides what a model has room for. It does not decide what a model can follow. Given thirty-six tool schemas and a five-thousand-token briefing, a 4B model picks the wrong tool or none, however large its window. The profile makes that second choice:
small offers the fourteen tools in SMALL_TOOLS, which cover a coding turn, and a one-screen briefing.full offers everything.auto picks small for a model on this machine or a window of 32k tokens or less, and full otherwise. A model whose window is unknown counts as full.profile_for(spec, profile) makes the choice, and the budget above still sizes the tool results. The first cell below checks the profile, the next two the budget:
Resolve profile for spec: auto is small for a local or small-window model.
_local = ModelSpec('gemma-e4b', 'litert', 'litert-community/gemma-4-E4B-it-litert-lm', 16_384)
test_eq(profile_for(_local), 'small')
test_eq(profile_for(ModelSpec('s', 'remote', 'x/y', 200_000)), 'full')
test_eq(profile_for(ModelSpec('s', 'remote', 'x/y', 32_000)), 'small')
test_eq((profile_for(None), profile_for(ModelSpec('s', 'remote', 'x/y', 0))), ('full', 'full'))
test_eq((profile_for(_local, 'full'), profile_for(ModelSpec('s', 'remote', 'x/y', 200_000), 'small')), ('full', 'small'))
test_fail(lambda: profile_for(_local, 'tiny'), contains='profile')'16k window: no inlined skills, no memory/web tools, tool results clipped to 4096 chars'
The functions above, used:
_write = lambda t: config_path().write_text(t)
_old_cfg, _old_al = os.environ.get('RAMABANA_CONFIG'), MODEL_ALIASES
with tempfile.TemporaryDirectory() as d:
os.environ['RAMABANA_CONFIG'] = f'{d}/config.toml'; use_aliases(Path(d)/'models.json')
os.environ['AZ_TEST_KEY'] = 'k'
_write('[model."az-big"]\nmodel_id = "gpt-5.4"\nbase_url = "https://res.openai.azure.com/openai/v1"\napi_key_env = "AZ_TEST_KEY"\nctx = 200000\n')
spec = resolve('az-big')
test_eq((spec.runtime, spec.model_id, spec.ctx, spec.config['api_name']), ('remote', 'gpt-5.4', 200_000, 'openai'))
test_eq(CUSTOM['az-big']['note'], 'config.toml')
test_fail(lambda: save_model({'name': 'az-big', 'model_id': 'x', 'runtime': 'remote'}), contains='defined in')
with warnings.catch_warnings(record=True):
warnings.simplefilter('always')
_write('[model."half"]\nbase_url = "https://x"\n')
assert 'no model_id' in model_problems()[0] and 'az-big' not in CUSTOM
del os.environ['AZ_TEST_KEY']
if _old_cfg is None: os.environ.pop('RAMABANA_CONFIG')
else: os.environ['RAMABANA_CONFIG'] = _old_cfg
use_aliases(_old_al); _SYNC['key'] = NoneModelSpec(name='tiny', backend='litert', model_id='litert-community/gemma-4-E2B-it-litert-lm', ctx=8192, note='', config={})
Once registered, the name works like a table entry. unregister_model removes it from every table it was added to:
(False, False)
register_model refuses a runtime that no engine implements, so the mistake shows up when the name is added rather than when a turn cannot start:
_cat = {'gpt-5.5': {'capabilities': {'type': 'chat', 'limits': {'max_prompt_tokens': 272000}}}}
_orig_avail, _orig_cat = _copilot_available, copilot_catalog
_copilot_available, copilot_catalog = (lambda: True), (lambda ttl=300: _cat)
try:
register_model('cop-test', 'gpt-5.5', runtime='copilot')
test_eq(resolve('cop-test').runtime, 'copilot')
test_eq(resolve('cop-test').ctx, 272000)
test_eq(resolve('cop-test').local, False)
finally:
_copilot_available, copilot_catalog = _orig_avail, _orig_cat
MODELS.pop('cop-test', None); CUSTOM.pop('cop-test', None); _LOCAL_CTX.pop('cop-test', None)A tool schema tells the model what a tool is called and what arguments it takes. There are two ways to send them. The native channel uses the model API’s own tool field: the API checks the schemas, and tool calls come back as structured data. The tags channel, rishi’s tool_mode='tags', writes the schemas into the system prompt and reads tool calls back out of the reply text, written as Hermes-style tags. The local engines have always worked this way, which is why parse_tool_tags exists.
Native is better wherever it is open, but it is not always open. Claude Code receives tools as an in-process MCP server, and a company-managed Claude Code configuration can forbid every MCP server it did not approve. Without the tags channel, that policy would leave the agent unable to read a file. Rishi uses the native channel for Claude Code where its SDK can, and tags in the CLI or wherever the server is refused.
tool_channel(spec, chat) returns 'native' or 'tags'. It is the one place that picks. $RAMABANA_TOOL_CHANNEL overrides everything. Otherwise it asks the open chat, which knows which channel it ended up on, and without a chat it predicts from the spec: tags for Claude Code, and tags for a model that force_tags marked because a request refused its schemas. forget_forced_tags clears those marks so the native channel is tried again. budget_for takes the channel as an argument because the schemas cost prompt tokens on the tags channel.
The channel (‘native’ or ‘tags’) a model’s tool schemas travel on.
Forget which models were forced onto tags, so the wire is tried again.
Record that this model’s tools cannot travel on the wire here.
os.environ.pop('RAMABANA_TOOL_CHANNEL', None)
test_eq(tool_channel(ModelSpec('sonnet', 'remote', 'claude-sonnet-4-5', 200_000)), 'native')
os.environ['RAMABANA_TOOL_CHANNEL'] = 'tags'
test_eq(tool_channel('gpt-5.1'), 'tags')
del os.environ['RAMABANA_TOOL_CHANNEL']
force_tags('gpt-5.6-terra', 'the wire refused the tool schemas')
test_eq(tool_channel('gpt-5.6-terra'), 'tags')
test_eq(tool_channel('gpt-5.1'), 'native')
forget_forced_tags()
class _Chat:
def __init__(self, ch): self.tool_channel = ch
test_eq(tool_channel(ModelSpec('c', 'claude', 'claude-opus-5', 200_000), _Chat('native')), 'native')
test_eq(tool_channel(ModelSpec('c', 'claude', 'claude-opus-5', 200_000), _Chat('tags')), 'tags')
import rishi.claude
assert getattr(rishi.claude.ClaudeChat, 'tool_channel', None) is not None
test_eq(tool_channel(ModelSpec('sonnet', 'claude', 'claude-sonnet-5', 200_000)), 'tags')
test_eq(tool_channel('claude/claude-sonnet-5'), 'tags')
test_eq(tool_channel(ModelSpec('cop', 'copilot', 'gpt-5.5', 272_000)), 'native')Routing decides which model runs each job. It is the only place that does. Build one with the turn model you want, or leave it out to use $RAMABANA_MODEL, then turn in config.toml’s [jobs], then DFLT_TURN. name_for(job) gives the model name for a job, and spec(job) resolves it to a ModelSpec ready to run. To give one job a model of its own, set $RAMABANA_MODEL_<JOB>, name it in config.toml’s [jobs] (the env var wins), call set(name, job), or type /model JOB NAME in a session. A job with no entry in the policy uses the turn’s model, so adding a job to JOBS never leaves a caller with nothing.
When the turn runs on this machine, every job without a model of its own stays on it too, sub-agents included, so nothing leaves the machine.
When a job’s model cannot run here, spec moves the job to another hosted route it already knows, and never to a local model nobody named. When claude-haiku-5-5 cannot run, it tries claude-sonnet-5-5 first. alternatives(job) lists the routes in the order they are tried, ending with DFLT_FALLBACK. A hosted model whose key is missing counts as one that cannot run, for every job but the turn; the turn’s missing key is reported by Agent.start. A model whose answer failed or came back empty is set aside for DOWN_TTL, five minutes. Each move is written to notes, keyed by job, and news holds one line per model that fell, for the chat to show once. When no route is left, the error names the job, the model, and the key or command it needs. missing_key and needs build that part of the message.
A machine with no Claude Code and no Codex login cannot run the defaults, and nothing is chosen in their place: Ramabana asks for a provider instead. PROVIDERS lists the ones it can use and what each needs, provider_ready says which are set up here, and needs_setup(routing) says why the turn’s model cannot run, a missing runtime or a missing key, or nothing. setup_guide puts it together for a person: the providers, then each job, the model it runs on, where that was chosen (job_source: an env var, config.toml, the turn it follows, or the default) and whether it can run. The terminal prints it before a session whose turn cannot run, and ramabana setup adds a provider.
AWS Bedrock is reached through its OpenAI-compatible Mantle endpoint, https://bedrock-mantle.<region>.api.aws/v1, with a Bedrock API key in AWS_BEARER_TOKEN_BEDROCK sent as a bearer token; such keys are often short-lived, so renew them as AWS says. Model ids are Bedrock’s own, such as openai.gpt-5.4, and their windows come from fastllm’s bedrock_mantle table. Bedrock’s native Converse and InvokeModel APIs, and with them Claude on Bedrock, are not supported: fastllm has no SigV4 signing, no AWS credential chain or profile, and no Converse wire format.
image_route is the image job, which generate_image draws with: $RAMABANA_MODEL_IMAGE, config.toml’s [jobs] image, or DFLT_IMAGE, OpenAI’s gpt-image-1. It gives the endpoint, key and model of an OpenAI-shaped images API, such as an Azure deployment of gpt-image-1, or a sentence saying what to set up; Bedrock’s Mantle endpoint and OpenRouter have no images endpoint.
video_route is the video job in the same way: $RAMABANA_MODEL_VIDEO, config.toml’s [jobs] video, or DFLT_VIDEO, Google’s Veo 3. It gives api, gemini for Gemini’s long-running predictLongRunning API or openai for an OpenAI-shaped /videos API such as sora-2 on OpenAI or Azure, with the endpoint, key and model id. A model fastllm knows to return no video is refused, as is a local model or a host with no video endpoint, and each refusal says what to set up. video_route(name) resolves one named model, so a job started earlier is polled with the key it was started with.
Which model runs each job, and where a job goes when its model cannot run here.
Whether name names an on-device model, from the tables or its prefix, without resolving it.
The credential a cloud spec needs and this environment lacks: an env var, or CODEX_LOGIN; else ’’.
The video job’s endpoint, {name, api, base_url, api_key, model}, from name, $RAMABANA_MODEL_VIDEO, config.toml’s [jobs] video or DFLT_VIDEO; else what to set up.
The image job’s endpoint, {name, base_url, api_key, model}, from $RAMABANA_MODEL_IMAGE, config.toml’s [jobs] image or DFLT_IMAGE; else what to set up.
The providers Ramabana can use, which are set up here, and the model each job runs on and whether it can: nothing is chosen.
Why the turn’s model cannot run here, a missing runtime or key, or ’’ when it can.
Where job’s model comes from: an env var, config.toml, set, the turn it follows, or the default.
Whether provider key’s credential or runtime is on this machine; nothing is chosen by it.
With a hosted turn, completion and classification go to claude-haiku-5-5, one-shot prompts and summaries to gpt-6-luna, and sub-agents to claude-sonnet-5-5. With a local turn, every job stays on the turn’s model:
('sonnet', 'gpt-6-luna', 'claude-haiku-5-5')
test_eq((r.name_for('subagent'), r.name_for('summary')), ('claude-sonnet-5-5', 'gpt-6-luna'))
test_eq((r.name_for('completion'), r.name_for('classify')), (DFLT_SMALL, DFLT_SMALL))
test_eq([Routing(turn='gemma-e4b').name_for(j) for j in ('classify', 'summary', 'subagent')], ['gemma-e4b'] * 3)
test_eq(Routing(turn='gemma-e4b').alternatives('classify'), [])
test_eq(Routing().alternatives('classify')[0], DFLT_SUBAGENT)
test_eq(Routing().alternatives('classify')[-1], DFLT_FALLBACK)
test_eq(missing_key(ModelSpec('g', 'remote', 'gemini/x', 1)), '' if os.environ.get('GEMINI_API_KEY') else 'GEMINI_API_KEY')set resolves the name before storing it, so a misspelled model fails at the /model command instead of during the next turn:
'opus'
backends reduces the policy to the distinct runtime and model pairs it needs, so that one loaded model can serve several jobs:
{('claude', 'claude-haiku-5-5'),
('claude', 'claude-sonnet-5-5'),
('remote', 'codex/gpt-6-luna')}
summary prints the whole policy, with any fallbacks, for /model typed with no argument:
turn sonnet · cloud · 1000k ctx · in: text image
oneshot gpt-6-luna · cloud · 128k ctx · modalities unknown · context window unknown, assuming 128k
inline gpt-6-luna · cloud · 128k ctx · modalities unknown · context window unknown, assuming 128k
completion claude-haiku-5-5 · cloud · 1000k ctx · in: text image
classify claude-haiku-5-5 · cloud · 1000k ctx · in: text image
summary gpt-6-luna · cloud · 128k ctx · modalities unknown · context window unknown, assuming 128k
subagent claude-sonnet-5-5 · cloud · 1000k ctx · in: text image