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

available()[source]#
command = ''#
complete(system, messages, specs, max_tokens=0)[source]#
login_hint = ''#
npm_entry = ''#
npm_package = ''#
path_env = ''#
set_model(model)[source]#
supports_vision()[source]#

Whether the configured model accepts image blocks in user messages.

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.cli_setup.check_login(provider)[source]#
PyHydroGeophysX.llm.cli_setup.find_executable(provider)[source]#
PyHydroGeophysX.llm.cli_setup.find_node(launcher)[source]#
PyHydroGeophysX.llm.cli_setup.install_cli(provider, progress=<function <lambda>>)[source]#

Install only into the app’s user directory, validating official checksums.

PyHydroGeophysX.llm.cli_setup.install_root()[source]#
PyHydroGeophysX.llm.cli_setup.managed_executable(provider)[source]#
PyHydroGeophysX.llm.cli_setup.prepare_cli(provider, progress=<function <lambda>>)[source]#

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

available() → Tuple[bool, str][source]#
complete(system, messages, specs, max_tokens: int = 4096) → Dict[str, Any][source]#
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: OpenAIProvider

Any OpenAI-compatible chat-completions endpoint (DeepSeek, OpenRouter, local…).

available() → Tuple[bool, str][source]#
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

available() → Tuple[bool, str][source]#
complete(system, messages, specs, max_tokens: int = 1024) → Dict[str, Any][source]#
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: object

Base class: owns model + key config and one tool-enabled complete.

available() → Tuple[bool, str][source]#
complete(system: str, messages, specs, max_tokens: int = 0) → Dict[str, Any][source]#
id = ''#
label = ''#
property model: str#
set_api_key(api_key: str) → None[source]#
set_base_url(base_url: str) → None[source]#
set_model(model: str) → None[source]#
supports_vision() → bool[source]#

Whether the configured model accepts image blocks in user messages.

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_images most 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_PLACEHOLDER text; 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 openai or anthropic package.

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 _warm method 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_client keeps 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.

compact drops 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 model on provider_id can read image blocks.

Used to gate the studio capture_view tool: offering a screenshot tool to a text-only model wastes a turn and returns an API error.

PyHydroGeophysX.llm.providers.text_block(text: str) → Dict[str, Any][source]#

A neutral text block.

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 medium there 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_CUSTOM for a model that is not on the ladder.

Returns:

An effort accepted by the reasoning models: 'low', 'medium' or 'high'. A custom model gets DEFAULT_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 model belongs to on provider_id, else TIER_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_id selects on provider_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 model sits on for any provider, else TIER_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 tool entries are coalesced into a single user message of tool_result blocks, 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_items recent entries.

Long chats grow the prompt (and the bill) without bound. The cut always lands on a user text turn so assistant tool_calls never lose their paired tool results, which the Anthropic Messages API requires. When no safe boundary exists in the tail, every entry is kept. Images are capped separately by cap_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.

record is 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}]
PyHydroGeophysX.llm.runtime_options.openai_options(model, temperature=0.2, max_tokens=None, effort=None)[source]#
PyHydroGeophysX.llm.runtime_options.report_usage(record)[source]#

Tell every listener about one model call. A listener that raises is ignored.

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.