AI provider backend
core/ai/ — the multi-provider backend
underneath the AI Companion panel.
If ClipsAPI is "what the AI can do to a
clip," this is "how a request actually reaches a model and comes back
as a proposal" — the layer below ai_panel.py and
execute.py.
Shape of a request/response
core/ai/schemas.py defines the one shape every provider
client returns:
@dataclass
class AIResponse:
explanation: str = ""
python_code: str = ""
error: str = ""
@property
def ok(self):
return not self.error
error is set (with explanation/python_code
left blank) only when the request itself failed — network, auth, or
malformed model output. Callers must check error/ok
first and never assume python_code is present otherwise.
The provider registry
core/ai/providers.py's PROVIDERS dict is the single
source of truth for what providers exist:
| id | Label | Default model | pip package |
|---|---|---|---|
"anthropic" | Anthropic (Claude) | claude-sonnet-5 | anthropic |
"openai" | OpenAI (GPT) | gpt-5.5 | openai |
"gemini" | Google (Gemini) | gemini-pro-latest | google-genai |
DEFAULT_PROVIDER = "anthropic".
build_client(provider=None, model=None) -> AIClient
Falls back to DEFAULT_PROVIDER if provider is falsy;
raises ValueError for an unknown provider id. Instantiates the
concrete client class with model=model or entry["default_model"]
— no API key is passed here; each client resolves its own key from
key storage unless constructed with one
directly.
Other functions in this module, mostly consumed by Settings > AI:
is_installed(provider)— a cheap "is the SDK importable" check (importlib.util.find_spec, not a real import), with a disk-existence guard since a cached finder entry can lie after an external uninstall.install_command(provider, upgrade=False)— returns(mayapy_path, argv)formayapy -m pip install <package>. Deliberately does not execute it — Settings runs it via a non-blockingQProcessso installing an SDK doesn't freeze Maya's UI.list_models(provider, api_key)— a live API call to list that provider's chat-capable model ids, for the model-refresh button in Settings.invalidate_import_cache()—importlib.invalidate_caches(), called after an install finishes so the freshly-installed SDK is importable without restarting Maya.
mayapy -m pip install ... — a user's
system Python isn't relevant here (sys.executable inside
interactive Maya resolves to maya.exe, not
mayapy.exe).
The client interface
core/ai/client_base.py — the entire abstract surface is
one method:
class AIClient:
def generate_code(self, user_request, context=""):
raise NotImplementedError
context is a plain-text summary the caller assembles
(current selection, a domain-specific "concepts primer" — see
ai_panel.py's context_provider hook, documented in
AI Companion). The client folds it
into the prompt; it never fetches anything itself.
Each concrete client shares the same structural pattern:
- Lazy SDK import inside
generate_code(), never at module load — a missing SDK on one provider never breaks the others or any othercore/ai/caller. __init__(self, model=DEFAULT_MODEL, api_key=None), resolving the key from a constructor arg first, then key storage.- A forced tool/function call to a single
propose_scriptfunction takingexplanation(string) andpython_code(string), both required — the model cannot respond with free text; every successful call returns a structured proposal. - Any API failure is caught, logged (
traceback.print_exc()), and returned asAIResponse(error=...)rather than raised.
Provider-specific notes
Anthropic (anthropic_client.py)
messages.create(..., system=system_prompt_for_scope(),
tools=[...], tool_choice={"type": "tool", "name":
"propose_script"}). The system prompt goes in via a dedicated
system= kwarg. Tool input arrives already-decoded (a dict,
no manual JSON parsing needed) — the SDK guarantees well-formed
output against the declared schema via constrained decoding.
OpenAI (openai_client.py)
Chat Completions API (not the newer Responses API). The system
prompt goes in as a "system"-role message in the
messages list, not a dedicated kwarg — the one
structural difference from Anthropic worth remembering. Unlike
Anthropic, function arguments come back as a raw JSON
string (call.function.arguments), requiring an
explicit json.loads(...).
Gemini (gemini_client.py)
Wraps the unified google-genai package (not
the older, now-superseded google-generativeai package).
Default model is deliberately the alias gemini-pro-latest
rather than a dated concrete id, after a sibling tool once hardcoded
a specific Gemini model id that later 404'd once Google moved on.
System prompt goes in via
GenerateContentConfig(system_instruction=...); tool calling
is forced via ToolConfig(function_calling_config=FunctionCallingConfig(mode="ANY",
allowed_function_names=["propose_script"])).
Each client's fallback path (if the model somehow doesn't
return the forced tool call — "should be unreachable" per the code's
own comments) returns whatever free text the model did produce as an
AIResponse with error set, rather than crashing on a
missing field.
The system prompt
core/ai/prompts.py — SYSTEM_PROMPT is sent (via
system_prompt_for_scope(), currently a pass-through, kept as a
hook for a future narrower per-domain scope) with every request, alongside
build_user_message(user_request, context) (prefixes
f"Current context:\n{context}\n\n" when context is non-empty).
What it instructs, in summary:
- Frames the model as the AI companion embedded in CoreTools,
translating plain English into a
maya.cmdsPython script for the user to review and run.cmdsis already available in the exec namespace — explicitly told not toimport maya.cmds. - No domain restriction on what nodes it can touch, but told to be precise (scope to a clear name pattern or the current selection) rather than acting broadly or guessing.
- Hard rules: always call
propose_script(never respond with plain text); avoid unnecessary imports; never callcmds.file(save=True); never touch the filesystem outside Maya's own commands; never make network requests; write code that's readable/sanity-checkable by a lightly-technical user, since they have to approve it before it runs. - Ambiguous requests still get a best-effort script that resolves
context at runtime (e.g.
cmds.ls(selection=True)) rather than a clarification round-trip — there's no back-and-forth mechanism in this UI, so the model doesn't get to ask a follow-up question. - A non-actionable request (a general question, not a task) gets an
empty
python_codeand the actual answer inexplanationinstead.
This is prompt-level safety framing, not a sandboxing mechanism — see AI Companion's safety model for what actually enforces the boundary (a human reading the code before clicking Run).
API key storage
core/ai/key_storage.py — keys live in the OS keychain
via the keyring package, never in UserConfig's
plain registry-backed store (unlike Perforce credentials, which do live
there in plaintext — this is a deliberately stronger precedent for
anything that's an actual secret). Namespaced per-provider
(f"{provider}_api_key") under a service name distinct from any
other CoreTools-family tool sharing the same machine, so switching
provider never clobbers another provider's stored key, and two different
tools' keys never collide.
keyring_available() -> bool
get_api_key(provider="anthropic") -> str | None
set_api_key(provider, value) -> bool
clear_api_key(provider="anthropic") -> bool
Every function degrades to a safe default (False/None)
rather than raising if keyring isn't importable — Maya's
bundled Python doesn't ship it by default. Settings > AI disables the
key field entirely and shows an install hint
(mayapy -m pip install keyring) when
keyring_available() is false.