PyHydroGeophysX.llm package#
Submodules#
PyHydroGeophysX.llm.cli_models module#
Read Codex’s model picker through its local app-server protocol.
Only initialization and model/list are sent: no inference, thread or tool run. The CLI owns authentication and catalog refresh, just as it does for exec.
- PyHydroGeophysX.llm.cli_models.list_codex_models(provider, timeout: float = 20) list[str][source]#
Return visible model IDs from the CLI used by this provider.
Run off the GUI thread. A single deadline bounds the handshake and all catalog pages; closing the short-lived server also stops its pipe reader.
PyHydroGeophysX.llm.cli_providers module#
Model adapters using a user’s authenticated Codex / Claude Code CLI.
The CLI returns a structured proposal. Only the studio executes proposed tools, so its usual approval flow also applies to these providers. No SDK or copied login token is required. Each request includes the neutral conversation.
- class PyHydroGeophysX.llm.cli_providers.ClaudeCodeProvider(model='default', api_key=None, base_url=None)[source]#
Bases:
CliProvider- command = 'claude'#
- id = 'claude_code'#
- label = 'Claude Code CLI'#
- login_hint = 'Run claude in a terminal and sign in before using the studio.'#
- npm_entry = 'cli.js'#
- npm_package = '@anthropic-ai/claude-code'#
- path_env = 'PHGX_CLAUDE_CLI'#
- class PyHydroGeophysX.llm.cli_providers.CliProvider(model='default', api_key=None, base_url=None)[source]#
Bases:
Provider- command = ''#
- login_hint = ''#
- npm_entry = ''#
- npm_package = ''#
- path_env = ''#
- class PyHydroGeophysX.llm.cli_providers.CodexCliProvider(model='default', api_key=None, base_url=None)[source]#
Bases:
CliProvider- command = 'codex'#
- id = 'codex_cli'#
- label = 'Codex CLI'#
- login_hint = 'Run codex login in a terminal to sign in with ChatGPT.'#
- npm_entry = 'bin/codex.js'#
- npm_package = '@openai/codex'#
- path_env = 'PHGX_CODEX_CLI'#
- PyHydroGeophysX.llm.cli_providers.cli_failure(label, stdout, stderr, login_hint='')[source]#
What went wrong in a failed CLI call, in a sentence the user can act on.
The Claude Code CLI answers a failed call with a JSON envelope. A usage limit (HTTP 429) used to come out as the whole envelope followed by “sign in”, which sent the user to fix a login that was fine.
>>> envelope = ('{"is_error": true, "api_error_status": 429, "result": ' ... '"You have hit your session limit - resets 8:10pm"}') >>> print(cli_failure("Claude Code CLI", envelope, "", "Run claude and sign in.")) Claude Code CLI: usage limit reached. You have hit your session limit - resets 8:10pm >>> print(cli_failure("Codex CLI", "", "Not logged in", "Run codex login.")) Codex CLI: Not logged in Run codex login.
PyHydroGeophysX.llm.cli_setup module#
Discover or provision official native CLIs for the desktop, without PATH edits.
PyHydroGeophysX.llm.providers module#
Provider abstraction for the AQUAH assistant: OpenAI, Claude, compatible.
The chat panel keeps a single provider-neutral conversation and a neutral tool spec list; each provider adapter translates that to its own wire format, makes one tool-enabled call, and normalises the reply back. This lets the user switch provider and model at runtime without the panel knowing any provider details.
Neutral shapes used throughout:
tool spec:
{"name", "description", "parameters"(JSON schema)}message (user):
{"role": "user", "content": str | [block]}message (asst):
{"role": "assistant", "content": str|None, "tool_calls": [{"id", "name", "arguments"(dict)}], "_anthropic_content"(optional, opaque)}message (tool):
{"role": "tool", "id": str, "name": str, "content": any}complete() reply:
{"content": str|None, "tool_calls": [...], "_anthropic_content"(optional)}block (text):
{"type": "text", "text": str}block (image):
{"type": "image", "media_type": str, "data": str(base64)}
Only user messages carry blocks, and only they may hold images. That is not a
simplification: the OpenAI chat-completions API accepts images in a user part
list but requires role: "tool" content to be a plain string, so a figure
produced by a tool has to travel as a separate follow-up user message. Anthropic
would allow an image inside tool_result, but routing both providers the same
way keeps one code path and one transcript shape.
Model choice is offered as a three-step cost ladder rather than a flat list -
see MODEL_TIERS. Level 1 answers most requests, level 2 handles coding
and agent work, level 3 exists for what the levels below could not finish. Both
the desktop chat panel and the Streamlit sidebar read that one registry, so the
two surfaces always offer the same levels at the same prices.
The openai / anthropic SDKs are imported lazily, so importing this module
never requires either to be installed.
- class PyHydroGeophysX.llm.providers.AnthropicProvider(model: str, api_key: str | None, base_url: str | None = None)[source]#
Bases:
Provider- env_key = 'ANTHROPIC_API_KEY'#
- id = 'anthropic'#
- label = 'Claude (Anthropic)'#
- PyHydroGeophysX.llm.providers.DEFAULT_EFFORT = 'medium'#
Reasoning effort when the level does not name one, and for a custom model.
- PyHydroGeophysX.llm.providers.DEFAULT_REQUEST_TIMEOUT_S = 600.0#
Per-request timeout in seconds for LLM calls. Report generation and review can legitimately take several minutes, especially through a local CLI provider. Override with PHGX_LLM_TIMEOUT_S when a different limit is needed.
- PyHydroGeophysX.llm.providers.IMAGE_PLACEHOLDER = '[figure from an earlier step, dropped to save context]'#
Substituted for a figure that has aged out of the image budget, so the model knows a picture existed at that point instead of silently losing the step.
- PyHydroGeophysX.llm.providers.KEEPALIVE_S = 120.0#
How long an idle connection to the provider is kept for the next request, in seconds. The SDKs drop one after 5 s, which is less time than a person takes to read a proposed step and approve it, so step-by-step chat opened a new TCP and TLS connection before nearly every request. Override with the PHGX_LLM_KEEPALIVE_S environment variable; it never goes below the SDK’s own.
- PyHydroGeophysX.llm.providers.MODEL_PRICES_USD_PER_MTOK: Dict[str, Tuple[float, float]] = {'claude-haiku-4-5': (1.0, 5.0), 'claude-opus-4-8': (5.0, 25.0), 'claude-opus-5': (5.0, 25.0), 'claude-sonnet-4-6': (3.0, 15.0), 'claude-sonnet-5': (2.0, 10.0), 'deepseek-chat': (0.28, 0.42), 'gpt-4.1': (2.0, 8.0), 'gpt-4.1-mini': (0.4, 1.6), 'gpt-4o': (5.0, 15.0), 'gpt-4o-mini': (0.15, 0.6), 'gpt-5.6-luna': (0.2, 1.2), 'gpt-5.6-sol': (5.0, 30.0), 'gpt-5.6-terra': (2.0, 12.0)}#
Approximate list price in USD per million input / output tokens, by model id. Rates move, and an agent turn re-sends its transcript, so read these as the relative cost of one level against the next rather than as a bill.
- PyHydroGeophysX.llm.providers.MODEL_TIERS: Dict[str, Dict[str, Any]] = {'level1': {'effort': 'low', 'headline': 'Default requests', 'models': {'anthropic': 'claude-haiku-4-5', 'openai': 'gpt-5.6-luna'}, 'name': 'Level 1', 'purpose': 'Most simple tasks: reading a file, setting a parameter, a short answer.'}, 'level2': {'effort': 'medium', 'headline': 'Complex requests', 'models': {'anthropic': 'claude-sonnet-5', 'openai': 'gpt-5.6-terra'}, 'name': 'Level 2', 'purpose': 'Coding, reasoning, agent loops, and complex retrieval.'}, 'level3': {'effort': 'high', 'headline': 'Genuinely hard requests', 'models': {'anthropic': 'claude-opus-5', 'openai': 'gpt-5.6-sol'}, 'name': 'Level 3', 'purpose': 'Escalate here only when a lower level could not solve it.'}}#
The three-step routing ladder offered in the chat settings and the Streamlit sidebar. Sending every request to a flagship model is the expensive default; starting at level 1 and escalating only when a level cannot finish the job costs far less over a session, because most turns in a studio conversation are short and mechanical. Each tier names one model per provider so the same choice survives a provider switch.
- class PyHydroGeophysX.llm.providers.OpenAICompatibleProvider(model: str, api_key: str | None, base_url: str | None = None)[source]#
Bases:
OpenAIProviderAny OpenAI-compatible chat-completions endpoint (DeepSeek, OpenRouter, local…).
- env_key = 'OPENAI_COMPAT_API_KEY'#
- id = 'openai_compatible'#
- label = 'OpenAI-compatible'#
- class PyHydroGeophysX.llm.providers.OpenAIProvider(model: str, api_key: str | None, base_url: str | None = None)[source]#
Bases:
Provider- env_key = 'OPENAI_API_KEY'#
- id = 'openai'#
- label = 'OpenAI'#
- PyHydroGeophysX.llm.providers.PROVIDER_ALIASES = {'anthropic': 'anthropic', 'claude': 'anthropic', 'gpt': 'openai', 'openai': 'openai'}#
Provider names used by callers that predate this module - the Streamlit sidebar and the agents engine both say “claude” where the adapters say “anthropic”.
- class PyHydroGeophysX.llm.providers.Provider(model: str, api_key: str | None, base_url: str | None = None)[source]#
Bases:
objectBase class: owns model + key config and one tool-enabled
complete.- id = ''#
- label = ''#
- property model: str#
- PyHydroGeophysX.llm.providers.TEXT_ONLY_MODEL_HINTS = ('deepseek-chat', 'deepseek-reasoner', 'deepseek-coder')#
Model-name fragments that identify a text-only model on an otherwise vision-capable provider. Matched case-insensitively as a substring, so version suffixes (“deepseek-chat-v3”) are covered without new entries.
- PyHydroGeophysX.llm.providers.TIER_CUSTOM = 'custom'#
Selected when the model does not belong to any tier - a hand-typed name, an OpenAI-compatible endpoint, or a model pinned from an earlier session.
- PyHydroGeophysX.llm.providers.TIER_ORDER = ('level1', 'level2', 'level3')#
Tier ids in ladder order, cheapest first.
- PyHydroGeophysX.llm.providers.as_blocks(content: Any) List[Dict[str, Any]][source]#
Normalise neutral message content to a list of well-formed blocks.
- PyHydroGeophysX.llm.providers.cap_images(messages: List[Dict[str, Any]], max_images: int = 2) List[Dict[str, Any]][source]#
Keep image blocks only in the
max_imagesmost recent messages carrying one.A screenshot costs roughly one to two thousand tokens on every subsequent call, so a session that captures a figure per step would re-send the whole gallery each turn. Older images become
IMAGE_PLACEHOLDERtext; the input list is never mutated.
- PyHydroGeophysX.llm.providers.has_image(content: Any) bool[source]#
Whether neutral message content carries at least one image block.
- PyHydroGeophysX.llm.providers.image_block(data: str, media_type: str = 'image/png') Dict[str, Any][source]#
A neutral image block from base64-encoded image
data.
- PyHydroGeophysX.llm.providers.keepalive_http_client(sdk: Any) Any | None[source]#
The SDK’s own HTTP client, keeping idle connections for
KEEPALIVE_S.Only the idle expiry differs from what the SDK would build for itself; connection limits, redirects, proxies and timeouts stay the SDK’s defaults. Shared with
BaseAgent, whose workflow agents talk to the same APIs.- Parameters:
sdk (module) – The imported
openaioranthropicpackage.- Returns:
None when the SDK does not expose its default client and limits, which leaves the SDK to build its own exactly as before.
- Return type:
httpx.Client or None
- PyHydroGeophysX.llm.providers.make_provider(provider_id: str, model: str | None = None, api_key: str | None = None, base_url: str | None = None) Provider[source]#
Build a provider, filling model/key/base_url from the environment by default.
- PyHydroGeophysX.llm.providers.normalise_provider_id(provider_id: str | None) str[source]#
Map a caller’s provider name onto an adapter id (
claude->anthropic).
- PyHydroGeophysX.llm.providers.prewarm(provider: Any) Thread | None[source]#
Start a first request’s one-off work on a background thread.
The first request of a session paid about 1.5 s before anything reached the network: importing the SDK, then the resource module behind the endpoint, then building the client. None of that depends on what the user is about to ask, so the chat starts it as soon as they begin typing and the request finds it done.
- Parameters:
provider (Provider) – The provider to warm. Anything without a
_warmmethod is ignored.- Returns:
The daemon thread doing the work, or None when there is nothing to do. Nothing waits for it: a request that starts first simply does the same work itself, and the lock in
_ensure_clientkeeps the two from each building a client.- Return type:
threading.Thread or None
- Raises:
None – Failures are swallowed: a missing SDK, a missing key or a broken install is reported by the request that needs it, not by a warm-up nobody asked for.
Examples
>>> prewarm(object()) is None True
- PyHydroGeophysX.llm.providers.price_label(model: str | None, compact: bool = False) str[source]#
"$0.20 / $1.20 per Mtok"for a known model, else an empty string.compactdrops the unit, for a status line that has to stay on one row; the ladder itself spells the unit out, so it is stated somewhere.
- PyHydroGeophysX.llm.providers.provider_has_tiers(provider_id: str) bool[source]#
Whether the ladder names a model for every tier on
provider_id.
- PyHydroGeophysX.llm.providers.supports_vision(provider_id: str, model: str | None) bool[source]#
Whether
modelonprovider_idcan read image blocks.Used to gate the studio
capture_viewtool: offering a screenshot tool to a text-only model wastes a turn and returns an API error.
- PyHydroGeophysX.llm.providers.tier_effort(tier_id: str | None) str[source]#
How hard the model should think, for a level of the ladder.
Effort is the other half of the dial the ladder turns. Every level-1 model is a reasoning model, so leaving effort at
mediumthere made the cheap level slow: chat drives the studio one tool call at a time, and each call is a separate request that pays a full reasoning pass before it can answer “navigate to the ERT module”. Level 1 exists for exactly the requests that do not need that.- Parameters:
tier_id (str or None) – A key of
MODEL_TIERS, or None /TIER_CUSTOMfor a model that is not on the ladder.- Returns:
An effort accepted by the reasoning models:
'low','medium'or'high'. A custom model getsDEFAULT_EFFORT, since nothing is known about what it is for.- Return type:
str
- Raises:
None –
Examples
>>> tier_effort('level1'), tier_effort('level2'), tier_effort('level3') ('low', 'medium', 'high') >>> tier_effort(None) 'medium' >>> tier_effort(TIER_CUSTOM) 'medium'
- PyHydroGeophysX.llm.providers.tier_for_model(provider_id: str, model: str | None) str[source]#
Which tier
modelbelongs to onprovider_id, elseTIER_CUSTOM.The reverse lookup keeps the level selector honest when the model is set by an environment variable, a saved session, or hand-typed into the model box.
- PyHydroGeophysX.llm.providers.tier_label(tier_id: str, provider_id: str, with_model: bool = True) str[source]#
One line naming a tier, and on a covered provider its model and price.
Example:
Level 2 - Complex requests (gpt-5.6-terra, $2.00 / $12.00 per Mtok).
- PyHydroGeophysX.llm.providers.tier_model(tier_id: str, provider_id: str) str | None[source]#
The model a
tier_idselects onprovider_id, or None if untiered.Returns None for a provider the ladder does not cover (an OpenAI-compatible endpoint, say), which is the caller’s cue to fall back to manual entry.
- PyHydroGeophysX.llm.providers.tier_of_model(model: str | None) str[source]#
Which tier
modelsits on for any provider, elseTIER_CUSTOM.Lets a caller keep the level a user chose when they switch provider: the saved model belongs to the old provider’s ladder, and the level it names is what should carry over.
- PyHydroGeophysX.llm.providers.to_anthropic_messages(messages: List[Dict[str, Any]]) List[Dict[str, Any]][source]#
Translate neutral messages to Anthropic format (system is passed separately).
Consecutive
toolentries are coalesced into a singleusermessage oftool_resultblocks, as the Messages API requires, and adjacent user messages are merged by_push_user().
- PyHydroGeophysX.llm.providers.to_anthropic_tools(specs: List[Dict[str, Any]]) List[Dict[str, Any]][source]#
- PyHydroGeophysX.llm.providers.to_openai_messages(system: str, messages: List[Dict[str, Any]]) List[Dict[str, Any]][source]#
- PyHydroGeophysX.llm.providers.to_openai_tools(specs: List[Dict[str, Any]]) List[Dict[str, Any]][source]#
- PyHydroGeophysX.llm.providers.to_responses_input(messages)[source]#
Keep reasoning and function calls together across stateless tool turns.
- PyHydroGeophysX.llm.providers.window_messages(messages: List[Dict[str, Any]], max_items: int = 60, max_images: int = 2) List[Dict[str, Any]][source]#
Trim a long neutral conversation to roughly
max_itemsrecent entries.Long chats grow the prompt (and the bill) without bound. The cut always lands on a
usertext turn so assistanttool_callsnever lose their pairedtoolresults, which the Anthropic Messages API requires. When no safe boundary exists in the tail, every entry is kept. Images are capped separately bycap_images(), because a handful of figures can outweigh a long text transcript.
PyHydroGeophysX.llm.runtime_options module#
Per-process agent settings; never persist credentials.
- PyHydroGeophysX.llm.runtime_options.add_usage_listener(listener)[source]#
Call
listener(record)after every model call; returns a remover.recordis the agent’s usage ledger entry: provider, model, prompt and completion tokens, and the estimated cost in USD.>>> seen = [] >>> remove = add_usage_listener(seen.append) >>> report_usage({'total_tokens': 3}) >>> remove(); report_usage({'total_tokens': 4}) >>> seen [{'total_tokens': 3}]
Module contents#
Qt-free LLM provider layer shared by the desktop and web chat agents.
PyHydroGeophysX.llm.providers holds the neutral message state machine
and the OpenAI / Anthropic / OpenAI-compatible adapters. The module used to
live at PyHydroGeophysX.qt_apps.agent.providers and is still re-exported
from there for compatibility; new code should import from here.