Skip to content

refactor(config)!: consolidate LLM credential resolution and remove the vendor endpoint - #26

Merged
HackedRico merged 21 commits into
mainfrom
feat/consolidate-llm-profile-resolution
Aug 27, 2026
Merged

refactor(config)!: consolidate LLM credential resolution and remove the vendor endpoint#26
HackedRico merged 21 commits into
mainfrom
feat/consolidate-llm-profile-resolution

Conversation

@HackedRico

Copy link
Copy Markdown
Collaborator

Description

Consolidates LLM credential resolution into one implementation, and removes the vendor gateway hostname from the repo.

Before: config.py::llm_defaults was hardcoded to the yaml llm block while llm_client.py::get_llm_provenance took a profile argument. Each had its own copy of the api_key_env lookup, and LLMClient._openai_compatible_generate carried a third with a comment saying it existed only because that path bypassed config.py.

After: one resolver, llm_client.resolve_env_indirection, with three call sites. config.py exposes profile_defaults(profile) and keeps llm_defaults() as a thin alias, so the cti profile resolves through exactly the same code as llm.

Resolution order

Tier Source Wins when
1 conf/default.yml, overlaid by conf/local.yml the floor
2 .env via the *_env keys see below
3 UI Global Model Configuration, per request always, unless fields_locked

Secrets and endpoints deliberately differ at tier 2. api_key resolves env first, so rotating .env takes effect without editing a tracked file. api_base resolves yaml first, so a deployment can pin an endpoint on disk. Both are declared in ENV_INDIRECT_FIELDS.

Refusing to guess an endpoint

conf/default.yml shipped api_base: https://models.k8s.aip.mitre.org/v1 for both profiles. Both now ship empty and name MCP_LLM_API_BASE.

An unresolved api_base is refused rather than passed through. dspy_lm_kwargs_from_settings dropped a falsy api_base entirely, which left LiteLLM routing openai/* models to its built in https://api.openai.com/v1, sending the prompt and the deployment's key to a provider nobody chose. The guard sits at dspy_lm_kwargs_from_settings, which is the one place all four dspy.LM() construction sites pass through, with a friendlier check earlier in resolve_llm_config.

No credential in plaintext

Three surfaces closed:

  • conf/local.yml: set_config posted api_key verbatim. Secrets are now stripped from the whole file on every write, so a save also scrubs a key an earlier build left behind.
  • Browser localStorage: globalConfig and every saved endpoint profile were serialized as is. Stripped at the storage boundary, and load purges what is already stored.
  • Both filters match /api_?key/i by name at any depth rather than listing fields, because the enumerated lists had already fallen behind the UI and were missing embed_api_key, plan_api_key and cti_rag_api_key. api_key_env is kept: it names a variable, not a value.

Also fixed, found while verifying the above

  • conf/local.yml replaced conf/default.yml wholesale, so any partial file dropped the *_env wiring and silently disabled .env resolution. It is now overlaid key by key. This mattered once secrets were stripped: a UI save produced a file with neither a key nor the wiring to find one.
  • A localStorage apiBase cached before the vendor URL was removed outranked MCP_LLM_API_BASE on every request. The stored blob is now versioned and a pre v2 blob drops the ambient apiBase. Named endpoint profiles keep theirs, since those are explicit user artifacts.
  • The splash page reported configured from api_key alone, which stopped being a complete precondition. It now names the specific variables that failed to resolve.
  • author.py and plan_execute.py called mlflow.set_experiment at module scope. That is a network round trip, so importing either module blocked whenever the tracking server was down, and discover_workflows swallowed the exception and dropped both workflows from the registry. Only set_experiment is deferred: dspy.autolog stays eager because it pins dspy.settings to the first asyncio task that reaches it, and each /execute runs in its own task.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Upgrade note

Existing deployments must add MCP_LLM_API_BASE to plugins/mcp/.env. .env.example never carried that line, so every existing .env has only MCP_LLM_API_KEY and the first run after this change will fail with an actionable error naming the variable.

How Has This Been Tested?

75 passed across test_config.py, test_set_config_secrets.py, test_author_guards.py and test_llm_client.py. Three of those files are new (49 cases).

Every guard was mutation tested rather than assumed. Reverting each fix fails exactly the tests that should catch it:

Mutation Fails
re-nest the api_base guard under openai_compatible 3 test_api_base_required_for_every_provider cases
disable the dspy_env guard 4 TestDspyLmKwargs refusal cases
drop the yaml side strip test_strips_whitespace_from_yaml
revert the secret filter to a fixed list test_strips_every_key_field_the_ui_uses
make the secret scrub non-recursive test_strips_nested_secrets, test_strips_secrets_inside_lists
restore replace-not-merge in load_config 2 TestLocalYmlMerge cases
defer dspy.autolog again test_both_workflows_configure_from_separate_tasks

Resolution was also traced end to end against a live endpoint. One .env pair in, identical values out of all four consumers:

resolve_llm_config  [Author/PlanExec]  base=.../v1  key=set  resolver=YES
llm_defaults        [direct run]       base=.../v1  key=set  resolver=YES
profile_defaults('cti')                base=.../v1  key=set  resolver=YES
get_llm_provenance('cti', runtime)     base=.../v1  key=set  resolver=YES

flake8 with the repo config shows no new violations: every changed file matches its violation count on main, and the three new test files are clean. flake8 . --select=E9,F63,F7, the CI hard gate, passes.

Pre-existing test failures, not caused by this branch

Reviewers running the whole directory will hit these on main too:

  • test_relation_extractor.py:19 imports cti_relation_extractor, which does not exist (the module is cti_relationships.py), aborting collection for the directory.
  • test_range_integration.py needs plugins/range/mcp_server.py, which is not vendored here.
  • test_model_config.py::TestConfigStructure::test_cti_has_toggle_fields requires a stream field that the cti block has never had.

test_model_config.py also POSTs {"config": {...}} at a live server, and set_config stores arbitrary top level keys verbatim, so running it corrupts conf/local.yml. Harmless now that local.yml is overlaid rather than substituted, but worth its own fix.

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix is effective or that my feature works

…oint

conf/default.yml shipped a MITRE gateway hostname as the default
api_base for both the llm and cti profiles. Both now ship empty and name
MCP_LLM_API_BASE through a new api_base_env key, mirroring the existing
api_key_env pattern, so no deployment's endpoint is baked into the repo.

An unresolved api_base is now refused rather than passed through.
dspy_lm_kwargs_from_settings drops a falsy api_base entirely, which
leaves LiteLLM routing openai/* models to its built-in
https://api.openai.com/v1, so a deployment that missed the env var would
have quietly sent CTI prompts to a provider it never chose.
resolve_llm_config now raises, matching the api_key check beside it and
the identical check get_llm_provenance already performs on the cti path.

Whitespace is stripped before normalization because
normalize_openai_api_base treats a blank but truthy string as a real URL
and would turn it into the relative path "/v1".

Also removes the hostname from two tests and one code comment.

BREAKING CHANGE: deployments must set MCP_LLM_API_BASE in plugins/mcp/.env
or pin llm.api_base in conf/local.yml. Runs raise ValueError otherwise.
llm_defaults() was welded to the `llm` block while get_llm_provenance()
took a profile argument, so the two paths each grew their own copy of the
api_key_env and api_base_env lookups. LLMClient carried a third, with a
comment noting it existed only because that path bypassed config.py.

resolve_env_indirection() in llm_client.py is now the single
implementation. It reads the *_env indirection for every field named in
ENV_INDIRECT_FIELDS, consumes those keys so they never reach the settings
dict handed to DSPy, strips values before normalization, and coerces
provider with `or` rather than setdefault so an explicit yaml null no
longer survives into the callers that branch on it.

config.py gains profile_defaults(profile) and keeps llm_defaults() as a
thin alias, so the cti profile resolves through exactly the same code as
the llm profile.

The api_key fallback in _openai_compatible_generate is removed: llm_cfg
now arrives resolved, so the workaround it documented no longer applies.
The guard sat inside `if provider == 'openai_compatible'`, so a request
body naming any other provider skipped it while model stayed
openai/gpt-oss-120b. dspy_lm_kwargs_from_settings then dropped the empty
api_base and LiteLLM routed the call, and the deployment's key, to its
built-in https://api.openai.com/v1.

provider reaches resolve_llm_config straight from the /execute request
body with no whitelist, and conf/default.yml locks only model and
api_base, so a lm_config of {"provider": "ollama"} was enough to bypass
the check. Only the normalization is provider-specific; the requirement
is not, and the ollama path needs a base to post to just as much.

Normalizes provider before branching as well, so an explicit yaml null
no longer reads as "not openai_compatible".
The override filter tested `value not in ("", None)` with no strip, so a
field containing only spaces counted as a real override. An api_base of
"   " is truthy, normalize_openai_api_base turns it into the relative
path "/v1", and that satisfies the api_base guard, so the run failed
deep inside the HTTP client instead of at the resolver with an
actionable message. A pasted URL with a trailing space had the same
shape and became "https://host/v1 /v1".

Both modelSelector.vue and the chat sidebar bind these inputs with a
bare v-model, so nothing trims them client side either. The yaml and env
tiers were already stripped in resolve_env_indirection; this makes the
stated invariant true on all three.
All four dspy.LM() constructions in the plugin take their kwargs from
dspy_lm_kwargs_from_settings, which makes it the only place that can
guarantee the fallback never happens:

  dspy_env.py:132              plan_execute.py:60
  workflows/author.py:248      utilities/llm_client.py:172

resolve_llm_config guards the /execute path, but plan_execute and author
fall back to llm_defaults() when no lm_obj is passed, and llm_defaults()
returns an empty api_base by design. That was safe while yaml always
carried a base and is not any more. plan_execute then forwards the empty
DSPY_API_BASE to every spawned MCP subprocess, which repeats it.

Dropping the falsy api_base was what let LiteLLM apply its own default,
so this checks before assembling the kwargs rather than after. The
`and settings.get("api_base")` on custom_llm_provider goes away with it,
since a base is now guaranteed to be present.
llm_configured came from the api_key alone, which was a complete
precondition until api_base moved out of the shipped yaml. Pull this
branch onto a deployment whose .env carries only MCP_LLM_API_KEY and the
splash page shows a green "configured" tag, omits the empty base rather
than showing it blank, suppresses the warning banner, and then every run
fails in the resolver. The suppressed banner also claimed that setting
MCP_LLM_API_KEY was sufficient, which is no longer true.

The page now reports the specific env vars that failed to resolve, so
the diagnostic names the thing the operator has to fix instead of a
fixed string. This is the one page whose stated job is telling the
operator whether the plugin is wired up.
Nothing in the suite imported app/config.py, so both guards could be
deleted with a byte-identical test result. These 26 cases pin the three
merge tiers, the shared env indirection, and the two refusals.

Mutation checked rather than assumed. Re-nesting the api_base guard under
`if provider == "openai_compatible"` fails the three
test_api_base_required_for_every_provider cases; disabling the dspy_env
guard fails all four TestDspyLmKwargs refusal cases; restoring either
makes them pass again.

_load_defaults is stubbed via a fixture so the tests read neither
conf/default.yml nor the developer's .env.
The env var is required now but appeared only in .env.example. Install
step 5 still said "configure model credentials through the UI or
environment/local config" without naming either variable, so a new
operator had no way to reach a working install from the README alone.

The local.yml advice was worse than absent. load_config returns local.yml
INSTEAD of default.yml with no merge, so an operator following the old
"pin llm.api_base in conf/local.yml" wrote a two-line file, lost
api_key_env with it, and was then told to set MCP_LLM_API_KEY, which they
had already set correctly. Both the yaml comment and the README now say
to copy the whole file and spell out that local.yml replaces rather than
merges.

The README sample also nested the llm block under a top-level `mcp:` key
that nothing reads, and omitted the *_env keys entirely. Corrected to
the shape config.py actually loads.
Several comments added on this branch restated what the code already
says or carried three lines of history where one would do. Trimmed to
the reason a reader cannot infer from the line below it. No behaviour
change; tests unchanged and still passing.
Consolidating both fields under one precedence inverted api_key. On main
a named api_key_env always won; the shared resolver let a yaml literal
shadow it, so a key written to conf/local.yml silently pinned itself and
rotating MCP_LLM_API_KEY did nothing.

Secrets now resolve env-first, endpoints stay yaml-first. Verified
against main's semantics across all four cases.

Also covers the yaml-side strip, which no test reached.
The Save button posts api_key with every save and set_config wrote the
payload verbatim, leaving the key in plaintext on disk beside tracked
config. Secrets are stripped from every section on write, so a save also
clears a key an earlier build left behind.

Reload now goes through reload_config: load_config is lru_cached, so the
old call read back the pre-write contents.
globalConfig and every saved endpoint profile were serialized to
localStorage verbatim, so the key sat in plaintext readable by anything
on the origin. Stripping at the storage boundary covers both, and load
purges a key an earlier build already wrote.

The Save payload no longer sends api_key either, since set_config
refuses to persist it.
get_llm_provenance was the last resolver still gating its api_base raise
on openai_compatible, contradicting the invariant the other two now hold
and test for.

author.run() prechecked only api_key, so a partial lm_obj failed at
dspy.LM() after the AsyncExitStack had already spawned every MCP
subprocess. Both credentials are now checked together, before that.

Adds provenance guard tests and the first coverage of the readiness
payload, including that neither leaks the key.
It still described two credentials and claimed the UI never writes to
disk. Now names MCP_LLM_API_BASE, records that set_config strips secrets
from conf/local.yml, and states the per-field precedence.
author.py and plan_execute.py called set_tracking_uri, set_experiment and
dspy.autolog at module scope. set_experiment is a network round trip, so
importing either module blocked whenever the tracking server was down,
which put both workflows out of reach of a test, a linter, or anything
running before MLflow comes up. llm_client.init_mlflow already documents
the rule these two broke.

Both now initialise lazily on the first run() call, which makes the
author credential precheck testable for the first time.
globalConfig.apiBase is restored from localStorage, and applyServerDefaults
only fills it when blank, so a stored value survives forever and ships as a
UI override on every /execute, where it outranks the env-resolved base. A
browser that cached the old vendor endpoint kept reaching it after the URL
was removed from the repo, and editing .env did nothing for that user.

The stored blob now carries a schema version, and loading a pre-v2 one
drops the ambient apiBase so the server default applies again. Named
endpoint profiles keep theirs: those are explicit user artifacts.
The first pass walked only the top level of each section. set_config
accepts arbitrary top-level keys, and a caller posting the documented
{"config": {...}} envelope produces {"config": {"cti": {api_key: ...}}},
which the one-level scrub walked straight past. Confirmed against a real
local.yml that still held a key after a save.

Now recursive through dicts and lists.
Deferring the whole MLflow block was too broad. mlflow.dspy.autolog calls
dspy.settings.configure, which pins ownership to the first asyncio task
that reaches it, and mcp_svc runs each /execute in its own task. So the
first workflow claimed the config and the second raised "can only be
called from the same async task", permanently, since the ready flag never
latched. At module scope neither call sat inside a task.

set_tracking_uri is a local assignment (0.01s against a dead host) and is
eager again too. Only set_experiment does I/O: it took 247s to fail with
the server down, which is what blocked imports.
The experiment used to exist by the time execute() looked it up, because
importing the workflow module created it. Deferring set_experiment moved
creation to the first run(), which is after this lookup, so a fresh store
filed runs under Default. On an author-only deployment nothing ever
created caldera-mcp-client-1 and every run stayed there.

create_experiment rather than set_experiment: the latter would mutate the
process-wide active experiment this block deliberately avoids.
Both filters listed field names and both had fallen behind the UI. The
browser blob persists capabilitySettings, so embed_api_key, plan_api_key
and cti_rag_api_key survived in localStorage while only the chat and
profile keys were stripped. The server list missed plan_api_key and
cti_rag_api_key the same way.

Both now match /api_?key/i at any depth. api_key_env is kept: it names a
variable, not a value, and dropping it would disable .env resolution.
local.yml replaced default.yml wholesale, so any partial file dropped the
api_key_env and api_base_env keys and silently disabled .env resolution.
The UI's Save writes exactly such a file, and scrubbing api_key out of it
turned that from "key stored in plaintext" into "no key at all": the
resolver reported No LLM API key while MCP_LLM_API_KEY was set correctly.

Merged key by key now, so local.yml only needs what it changes. Also
makes a malformed local.yml harmless, since the default sections survive.

Docs and the api_base error message updated: they told operators to copy
the whole file, which is no longer needed.
@HackedRico
HackedRico merged commit ade2d46 into main Aug 27, 2026
3 checks passed
@HackedRico
HackedRico deleted the feat/consolidate-llm-profile-resolution branch August 27, 2026 18:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant