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.
- Explains what a "Current context:" section in the user's own
message means (see
build_user_message()above): real, already-bound variables an embedding app supplied (clips,batch, etc.), named explicitly — not a hint to go find something similar. Told to call their own curated methods directly, neverimportanything to reach one (noimport coreflow, no similar guess), and to fall back to plaincmds(or say plainly inexplanationthat the app doesn't expose that yet) rather than inventing a plausible-sounding API that silently does nothing. Regression guard for a real, twice-reported bug: asked to create a script and queue it in Batch, the model hallucinated a fakeimport coreflow/coreflow.batch_queueAPI — nothing about it touched the real script library or queue. This paragraph is the shared, cross-provider fix (every app's AI integration goes through this one prompt); each embedding app's owncontext_providerstill carries the domain-specific half of the fix — see e.g. Batch's own_ai_context_summary(), which also had to add an explicit "don't just run the requested behavior directly, register it" rule after the shared fix alone still wasn't enough to stop a too-literal reading of a "print X" request. - 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.