spacr.qt.ai.providers

Provider abstraction — one class per AI vendor. Each shells out to the vendor’s own coding-agent CLI so authentication piggy-backs on the user’s chat subscription (Claude.ai Pro, ChatGPT Plus/Pro/Team, Google account) — no separate API billing.

  • Anthropic Claude → the claude CLI (“Claude Code”)

  • OpenAI ChatGPT → the codex CLI

  • Google Gemini → the gemini CLI

A fourth, HostedProvider, needs no CLI and no account: it asks spaCR’s relay, which answers with a free hosted model, and it is the default when none of the three is installed and signed in (default_provider()).

Each provider:

is_installed()   — is the CLI on PATH?
is_logged_in()   — best-effort check; falls back to "assume yes if
                   installed" (the actual auth error surfaces on
                   the first stream chunk).
stream_chat()    — spawn the CLI subprocess, yield stdout chunks.

Conversation context is carried by concatenating the full message history into each prompt (simplest approach that works uniformly across all three CLIs). For subscription users token count is not a concern.

How each CLI is installed is data, not prose: INSTALL_METHODS holds one row per way of installing it on each operating system, and both the install_hint a screen shows and the command spacr.qt.ai.cli_install runs are read from those rows. The GitHub CLI is described the same way by GitHubCli, which shares the CommandLineTool interface without being a chat provider.

Exceptions

HostedProviderRefused

spaCR's relay gave no advice: its free daily limit, or no connection.

ProviderFailed

A provider CLI exited with a non-zero status, so what it printed is an

Classes

ChatProvider

Abstract base for AI chat providers that shell out to a vendor CLI.

ClaudeCliProvider

Anthropic Claude via the claude (Claude Code) CLI.

CodexCliProvider

OpenAI ChatGPT via the codex CLI.

CommandLineTool

A vendor command-line tool spaCR can find, install and sign in to.

GeminiCliProvider

Google Gemini via the gemini CLI.

GitHubCli

The GitHub CLI, gh: installed and signed in like a provider.

HostedProvider

spaCR's hosted assistant: advice from spaCR's relay, no account needed.

InstallMethod

One way of installing a command-line tool on one operating system.

Functions

apply_provider_choice(→ bool)

Save a provider choice and use it at once in every open console.

configured_providers(→ List[ChatProvider])

Return the providers that can answer now, spaCR AI (free) first.

default_provider(→ Optional[ChatProvider])

Return the provider the AI switch uses.

describe_tool_call(→ str)

One line naming what a denied tool call would have done.

effective_provider_name(→ str)

Return the name of the provider Preferences shows as chosen.

explain_failure(→ Dict)

Have the active AI explain a failure once, for the console and the issue.

get_provider(→ Optional[ChatProvider])

Look up a provider by its short id.

gh_conda_row(→ InstallMethod)

The row that installs the GitHub CLI with conda.

github_cli(→ GitHubCli)

The GitHub CLI, described the way the AI providers are.

hosted_allowance_text(→ str)

One line saying how many free hosted-AI questions are left today.

hosted_cli_hint(→ str)

The hint for connecting a CLI that uses the user's own provider account.

hosted_daily_allowance_text(→ str)

The free allowance of spaCR AI, as Preferences states it.

hosted_provider(→ HostedProvider)

Return spaCR's hosted provider.

hosted_refusal_message(→ str)

The sentence shown when the hosted provider gives no advice.

install_hint_for(→ str)

The one-line install hint for tool name on platform.

install_methods_for(→ Tuple[InstallMethod, ...])

The ways of installing tool name on platform, in order.

list_providers(→ List[ChatProvider])

Return every vendor CLI provider, regardless of install state.

local_reset_time(→ str)

The time the free allowances reset, in this computer's local time.

platform_family(→ str)

Name the operating system INSTALL_METHODS is keyed by.

question_cache_key(→ str)

The key a fresh question's answer is saved under on this computer.

quote_path(→ str)

A path as it is typed in a command on platform.

terminate_all_streams(→ int)

Terminate active provider subprocesses and release their readers.

tool_rule(→ str)

The --allowedTools rule that allows exactly one denied tool call.

usage_bar(→ Tuple[Optional[int], str])

What the usage bar under the AI chat box shows for a provider.

with_ai_summary(→ Dict)

Return the report with the AI summary its provider allows.

write_report_summary(→ str)

Have the user's own AI summarise an anonymised report, or return "".

Module Contents

exception spacr.qt.ai.providers.HostedProviderRefused(message: str, reason: str = 'error')[source]

Bases: ProviderFailed

spaCR’s relay gave no advice: its free daily limit, or no connection.

A ProviderFailed, so the console writes the message as it stands after “[AI error]”.

Parameters:
  • message – the sentence the user reads.

  • reason – "quota", "network" or "error", from spacr.crashreport.RelayRefused, kept on the exception as reason.

Keep the reason; the message is the whole text shown.

exception spacr.qt.ai.providers.ProviderFailed(cli: str, exit_status: int, output_tail: str, login_command: str = '')[source]

Bases: RuntimeError

A provider CLI exited with a non-zero status, so what it printed is an error message and not an answer.

The three vendor CLIs report a failure the way any command-line tool does: a line on stdout or stderr, then a non-zero exit. A signed-out claude prints Not logged in · Please run /login and exits 1. In GitHub #117 an expired one printed Failed to authenticate: OAuth session expired and could not be refreshed. Streamed as if it were a reply, that line was shown as spaCR AI’s answer to a crash, and it was filed into GitHub issues as “spaCR AI’s analysis of this error”.

Parameters:
  • cli – the executable that failed, for the message.

  • exit_status – its exit status.

  • output_tail – the last lines it printed, already stripped.

  • login_command – the command that signs in to this provider, or "" when the caller did not say which provider this was.

Variables:
  • exit_status – the exit status, for a caller that needs the number.

  • output_tail – the quoted output, for a caller that needs the text.

Build the message a user reads after [AI error].

class spacr.qt.ai.providers.ChatProvider[source]

Bases: CommandLineTool, abc.ABC

Abstract base for AI chat providers that shell out to a vendor CLI.

Subclasses set the name/label/cli_name/install_hint/ login_command class attributes and implement stream_chat().

Variables:
  • name – short id (“claude” / “codex” / “gemini”).

  • label – human-readable label shown in the UI.

  • cli_name – executable expected on PATH.

  • install_hint – shell one-liner suggested for installation, built from install_methods.

  • login_command – shell one-liner the user runs to authenticate.

Create the provider with no child process running.

The running process is tracked so that cancelling a stream can actually terminate it – otherwise iterating the child’s stdout blocks indefinitely and the worker thread never exits.

cancel_stream() → None[source]

Kill the running subprocess (if any).

Terminating the process is what unblocks a stream that is waiting on stdout; a Python flag is only checked between chunks, and the next chunk may never arrive.

is_configured() → bool[source]

Return True when the CLI is both installed and logged in.

is_logged_in() → bool[source]

Best-effort — override per provider if a cheap check exists.

Default: assume yes when installed. The real auth error will surface as a normal subprocess failure on the first send.

source_of_key() → str[source]

Compat string for the old KeysDialog — now describes the CLI’s install/login state.

abstract stream_chat(messages: List[Dict], system: str = '', model: str | None = None) → Iterator[str][source]

Yield text chunks streaming from the CLI subprocess.

Parameters:

messages – conversation history as {role, content} dicts, oldest first; each implementation formats it into the CLI prompt.

class spacr.qt.ai.providers.ClaudeCliProvider[source]

Bases: ChatProvider

Anthropic Claude via the claude (Claude Code) CLI.

Create the provider with no child process running.

The running process is tracked so that cancelling a stream can actually terminate it – otherwise iterating the child’s stdout blocks indefinitely and the worker thread never exits.

allow_once(denials: List[Dict], session_id: str) → List[str][source]

Let the next stream_chat() resume session_id with these tool calls allowed, for that one reply only.

Parameters:
  • denials – entries of last_permission_denials the user approved.

  • session_id – the session they were denied in.

Returns:

the --allowedTools rules that will be passed.

stream_chat(messages: List[Dict], system: str = '', model: str | None = None) → Iterator[str][source]

Stream a chat completion from the claude CLI.

Parameters:
  • messages – conversation history as {role, content} dicts.

  • system – optional system prompt appended via --append-system-prompt.

  • model – optional model override passed via --model. When None the current response-speed setting supplies one.

Returns:

iterator yielding stdout text chunks.

class spacr.qt.ai.providers.CodexCliProvider[source]

Bases: ChatProvider

OpenAI ChatGPT via the codex CLI.

Create the provider with no child process running.

The running process is tracked so that cancelling a stream can actually terminate it – otherwise iterating the child’s stdout blocks indefinitely and the worker thread never exits.

stream_chat(messages: List[Dict], system: str = '', model: str | None = None) → Iterator[str][source]

Stream a chat completion from the codex CLI.

Parameters:
  • messages – conversation history as {role, content} dicts.

  • system – optional system prompt folded into the prompt body.

  • model – optional model override passed via --model. When None the current response-speed setting supplies one.

Returns:

iterator yielding stdout text chunks.

class spacr.qt.ai.providers.CommandLineTool[source]

A vendor command-line tool spaCR can find, install and sign in to.

The AI providers and the GitHub CLI share this, so “is it there?” and “is it signed in?” are asked one way for all four.

Variables:
  • name – short id, and the key of INSTALL_METHODS.

  • label – human-readable label shown in the UI.

  • cli_name – executable expected on PATH.

  • install_methods – the ways of installing it on this system, tried in order.

  • install_hint – the one-line hint built from install_methods.

  • login_command – the command a user runs to sign in.

  • status_command – a command that exits 0 when the tool is signed in, or () when the tool has none.

check_signed_in() → bool | None[source]

Ask the tool itself whether it is signed in.

Runs status_command with its output discarded – for the GitHub CLI that output is the token – so call it off the GUI thread.

Returns:

True when the command exits 0, False when it exits otherwise, cannot be started or runs past STATUS_TIMEOUT_S, and None when the tool has no status command to ask.

is_installed() → bool[source]

Return True when the tool’s executable is on PATH.

class spacr.qt.ai.providers.GeminiCliProvider[source]

Bases: ChatProvider

Google Gemini via the gemini CLI.

Create the provider with no child process running.

The running process is tracked so that cancelling a stream can actually terminate it – otherwise iterating the child’s stdout blocks indefinitely and the worker thread never exits.

stream_chat(messages: List[Dict], system: str = '', model: str | None = None) → Iterator[str][source]

Stream a chat completion from the gemini CLI.

Parameters:
  • messages – conversation history as {role, content} dicts.

  • system – optional system prompt folded into the prompt body.

  • model – optional model override passed via -m. When None the current response-speed setting supplies one.

Returns:

iterator yielding stdout text chunks.

class spacr.qt.ai.providers.GitHubCli[source]

Bases: CommandLineTool

The GitHub CLI, gh: installed and signed in like a provider.

spaCR reads a token from it (spacr.qt.ai.github_auth) to file an issue without a browser round-trip. Signing in is gh auth login; gh auth token exits 0 exactly when there is a token to read.

is_logged_in() → bool[source]

Return True when gh auth token has a token to give.

Runs a process, so call it off the GUI thread.

class spacr.qt.ai.providers.HostedProvider[source]

Bases: ChatProvider

spaCR’s hosted assistant: advice from spaCR’s relay, no account needed.

It sends only what _hosted_request() keeps, the anonymised error summary and the question, to the relay’s /diagnose route through spacr.crashreport.diagnose_remotely(). The answer is generated advice and is labelled as such. It is the provider used when no vendor CLI is installed and signed in (default_provider()), and it is unavailable when the network is forbidden (spacr.crashreport.network_allowed()).

Create the provider with no child process running.

The running process is tracked so that cancelling a stream can actually terminate it – otherwise iterating the child’s stdout blocks indefinitely and the worker thread never exits.

check_signed_in() → bool | None[source]

There is no sign-in to check.

Returns:

None.

is_configured() → bool[source]

Whether the hosted provider may be used: the network is allowed.

is_installed() → bool[source]

Nothing to install: the hosted provider is always present.

is_logged_in() → bool[source]

Nothing to sign in to: the relay needs no account.

source_of_key() → str[source]

Say where answers come from, in place of a CLI’s location.

stream_chat(messages: List[Dict], system: str = '', model: str | None = None) → Iterator[str][source]

Ask the relay and yield its advice, then a line saying what it is.

Parameters:
  • messages – conversation history as {role, content} dicts; only the anonymised summary and the newest question leave this computer.

  • system – ignored; the relay holds spaCR’s own prompt.

  • model – ignored; the relay chooses its free model.

Returns:

iterator yielding the answer.

Raises:

HostedProviderRefused – with a message that says the free daily limit is reached, or that the relay could not be reached.

class spacr.qt.ai.providers.InstallMethod[source]

Bases: NamedTuple

One way of installing a command-line tool on one operating system.

Variables:
  • needs – executables that must already be on PATH for this way to work, such as ("npm",).

  • command – the command as a user would type it.

  • runner – how spacr.qt.ai.cli_install starts it: "exec" runs the command’s first word as a program with the rest as its arguments, "shell" hands the command to bash -o pipefail -c (for a pipe such as curl ... | bash), and "cmd" runs it as cmd /c "<command>" on Windows.

property shown: str[source]

The command as it is shown on screen and copied.

Returns:

cmd /c "<command>" for the "cmd" runner, because that line runs whole in both Windows shells; otherwise the command itself.

spacr.qt.ai.providers.apply_provider_choice(name: str) → bool[source]

Save a provider choice and use it at once in every open console.

A vendor CLI that is not installed and signed in is saved but not applied; the consoles keep spaCR AI (free) until it is set up.

Parameters:

name – a name from PROVIDER_CHOICES.

Returns:

whether the chosen provider can answer now.

spacr.qt.ai.providers.configured_providers() → List[ChatProvider][source]

Return the providers that can answer now, spaCR AI (free) first.

The hosted provider leads whenever the network is allowed, since it is the default; a vendor CLI follows when it is installed and logged in.

spacr.qt.ai.providers.default_provider(preferred: str = '') → ChatProvider | None[source]

Return the provider the AI switch uses.

Parameters:

preferred – the provider Preferences names, or "".

Returns:

preferred when it can answer; otherwise spaCR AI (free); otherwise, with the network forbidden, the first vendor CLI that is installed and logged in; None when nothing can answer.

spacr.qt.ai.providers.describe_tool_call(denial: Dict) → str[source]

One line naming what a denied tool call would have done.

Parameters:

denial – one permission_denials entry.

Returns:

the command for a shell call, otherwise the tool’s name and its path, address or pattern.

spacr.qt.ai.providers.effective_provider_name(preferred: str = '') → str[source]

Return the name of the provider Preferences shows as chosen.

Parameters:

preferred – the stored preference; "" (never chosen) means spaCR AI (free).

Returns:

a name from PROVIDER_CHOICES.

spacr.qt.ai.providers.explain_failure(summary: Dict, provider: ChatProvider | None, timeout: float | None = None) → Dict[source]

Have the active AI explain a failure once, for the console and the issue.

spaCR AI (free) asks the relay’s /diagnose with the anonymised summary and spacr.crashreport.EXPLAIN_QUESTION; a vendor CLI answers spacr.crashreport.ai_summary_prompt() here (write_report_summary()). The same text is shown in the console and sent as the report’s ai_summary, so a failure costs one AI call. Blocks, so call it off the GUI thread.

Parameters:
  • summary – a summary from spacr.crashreport.public_summary().

  • provider – the active provider, or None.

  • timeout – seconds a vendor CLI may take; defaults to spacr.crashreport.AI_SUMMARY_TIMEOUT_S.

Returns:

{"text": explanation or "", "tier": "best"|"light"|"", "refused": "" or "quota"|"network"|"error"|"none"}, plus "saved": "local" when the answer was saved on this computer (no AI call, no network) or "relay" when the relay reused one.

An error explained before is not explained again: the answer saved on this computer (spacr.crashreport.saved_explanation()) comes first, for every provider, and each new answer is saved.

spacr.qt.ai.providers.get_provider(name: str) → ChatProvider | None[source]

Look up a provider by its short id.

Parameters:

name – provider id ("claude", "codex", "gemini" or HOSTED_PROVIDER_NAME).

Returns:

the matching provider, or None if no such id.

spacr.qt.ai.providers.gh_conda_row(prefix: str, platform: str) → InstallMethod[source]

The row that installs the GitHub CLI with conda.

Parameters:
  • prefix – spaCR’s own environment, sys.prefix.

  • platform – a sys.platform value, for quoting prefix.

Returns:

when prefix is a conda environment, a command that names it with --prefix and adds gh with --freeze-installed, so the command on screen says which environment changes and conda does not update the packages spaCR runs on; otherwise conda’s plain command, which installs into whichever environment conda treats as active.

spacr.qt.ai.providers.github_cli() → GitHubCli[source]

The GitHub CLI, described the way the AI providers are.

spacr.qt.ai.providers.hosted_allowance_text(allowance: Dict | None) → str[source]

One line saying how many free hosted-AI questions are left today.

Parameters:

allowance – what spacr.crashreport.fetch_allowance() returns, or None when it is not known.

Returns:

the translated line, or "" when nothing is known.

spacr.qt.ai.providers.hosted_cli_hint() → str[source]

The hint for connecting a CLI that uses the user’s own provider account.

Returns:

the translated sentence.

spacr.qt.ai.providers.hosted_daily_allowance_text() → str[source]

The free allowance of spaCR AI, as Preferences states it.

Returns:

the translated sentence.

spacr.qt.ai.providers.hosted_provider() → HostedProvider[source]

Return spaCR’s hosted provider.

spacr.qt.ai.providers.hosted_refusal_message(reason: str, scope: str = '', resets_at=None) → str[source]

The sentence shown when the hosted provider gives no advice.

Parameters:
  • reason – "quota", "network" or anything else.

  • scope – for a quota refusal, "install" (this install’s daily questions are used), "global" (spaCR’s shared free capacity is) or "ip".

  • resets_at – when the limit resets; defaults to the next 00:00 UTC.

Returns:

the translated sentence; a quota refusal also carries hosted_cli_hint().

spacr.qt.ai.providers.install_hint_for(name: str, platform: str) → str[source]

The one-line install hint for tool name on platform.

Built from the same rows the Install button runs, so the two cannot drift apart. On macOS and Linux the alternatives are joined by three spaces and then # or, which a POSIX shell reads as a comment; on Windows only the first row is shown, because cmd has no such comment and would hand the words after # to the installer as arguments.

Parameters:
  • name – a key of INSTALL_METHODS.

  • platform – a sys.platform value.

Returns:

the hint, or "" when the tool has no rows.

spacr.qt.ai.providers.install_methods_for(name: str, platform: str) → Tuple[InstallMethod, ...][source]

The ways of installing tool name on platform, in order.

Parameters:
  • name – a key of INSTALL_METHODS, such as "claude".

  • platform – a sys.platform value.

Returns:

the rows, or () for a tool spaCR cannot install.

spacr.qt.ai.providers.list_providers() → List[ChatProvider][source]

Return every vendor CLI provider, regardless of install state.

The hosted provider is not one of them: it has nothing to install or sign in to. hosted_provider() returns it.

spacr.qt.ai.providers.local_reset_time(resets_at=None) → str[source]

The time the free allowances reset, in this computer’s local time.

Parameters:

resets_at – an aware datetime or an ISO-8601 string; defaults to the next 00:00 UTC.

Returns:

HH:MM, followed by the weekday when it is not today.

spacr.qt.ai.providers.platform_family(platform: str) → str[source]

Name the operating system INSTALL_METHODS is keyed by.

Parameters:

platform – a sys.platform value.

Returns:

"win32" for any Windows value, "darwin" for macOS, and "linux" for everything else.

spacr.qt.ai.providers.question_cache_key(messages: List[Dict]) → str[source]

The key a fresh question’s answer is saved under on this computer.

Only the first question of a conversation is saved, and only when the question and whatever was pasted with it (console context, code, traceback) stay within spacr.crashreport.QUESTION_CACHE_CONTEXT_MAX characters. The key is spacr.crashreport.question_key() of the question and the active app; it is the same for every provider.

Parameters:

messages – conversation history as {role, content} dicts.

Returns:

the key, or "" when the answer must not be saved.

spacr.qt.ai.providers.quote_path(path: str, platform: str) → str[source]

A path as it is typed in a command on platform.

Parameters:
  • path – the path.

  • platform – a sys.platform value.

Returns:

on Windows the path in double quotes when it holds a space, else unchanged; elsewhere quoted for a POSIX shell.

spacr.qt.ai.providers.terminate_all_streams() → int[source]

Terminate active provider subprocesses and release their readers.

Returns:

Number of subprocesses for which termination was requested.

spacr.qt.ai.providers.tool_rule(denial: Dict) → str[source]

The --allowedTools rule that allows exactly one denied tool call.

A shell command is allowed as written, Bash(gh auth status); a web fetch for its host; any other tool by its name.

Parameters:

denial – one permission_denials entry.

Returns:

the rule, or "" when the entry names no tool.

spacr.qt.ai.providers.usage_bar(provider_name: str | None, allowance: Dict | None = None) → Tuple[int | None, str][source]

What the usage bar under the AI chat box shows for a provider.

For spaCR AI (free) the bar is the user’s share of today’s shared free capacity, neurons_left / share_neurons, from the relay’s dynamic allowance. For a vendor CLI there is no reliable usage source (CLI_USAGE_TEXT), so the bar is hidden and only a line is shown.

Parameters:
Returns:

(percent or None to hide the bar, translated text).

spacr.qt.ai.providers.with_ai_summary(summary: Dict, provider: ChatProvider | None) → Dict[source]

Return the report with the AI summary its provider allows.

spaCR AI (free) asks the relay to write it (summarize: true, charged to the user’s light-model allowance). A vendor CLI writes it here (write_report_summary()) and it travels as ai_summary. With no provider, or when the CLI gives nothing, the report is unchanged.

Parameters:
Returns:

a new summary dict.

spacr.qt.ai.providers.write_report_summary(summary: Dict, provider: ChatProvider | None, timeout: float | None = None) → str[source]

Have the user’s own AI summarise an anonymised report, or return "".

The provider is given spacr.crashreport.ai_summary_prompt() and nothing else. It runs on a thread of its own and is stopped after timeout seconds; a failure, a timeout or an empty answer gives "", and the report is then filed without a summary. Blocks, so call it off the GUI thread.

Parameters:
  • summary – a summary from spacr.crashreport.public_summary().

  • provider – a vendor CLI provider, or None.

  • timeout – seconds to wait; defaults to spacr.crashreport.AI_SUMMARY_TIMEOUT_S.

Returns:

the summary text, scrubbed and cut to spacr.crashreport.MAX_AI_SUMMARY_CHARS.