Skip to content

feat(llm, ui): Add native keyless Local LLM model support (Ollama, vLLM, LM Studio, LocalAI) and Office UI Settings - #30

Open
AhmadHassan-BTed wants to merge 15 commits into
HKUDS:mainfrom
AhmadHassan-BTed:main
Open

feat(llm, ui): Add native keyless Local LLM model support (Ollama, vLLM, LM Studio, LocalAI) and Office UI Settings#30
AhmadHassan-BTed wants to merge 15 commits into
HKUDS:mainfrom
AhmadHassan-BTed:main

Conversation

@AhmadHassan-BTed

Copy link
Copy Markdown

Overview

This PR adds support for using local/self-hosted LLM providers with OpenOPC without requiring a remote API key.

Supported local providers include Ollama, vLLM, LM Studio, LocalAI, Llama.cpp, and TGI. It also adds an LLM settings modal to the Office UI so the provider, model, API base, and API key can be configured directly from the UI.

The existing cloud providers are still supported.

Changes

1. Local LLM provider support

Updated opc/llm/provider.py to handle local/self-hosted providers.

  • has_credentials() now considers supported local providers valid without requiring an API key.

  • Added automatic api_base defaults for local providers when no endpoint is configured:

    • ollama/http://localhost:11434
    • vllm/http://localhost:8000/v1
    • localai/http://localhost:8080/v1
    • lmstudio/http://localhost:1234/v1
  • Added the api_key = "local" fallback for keyless OpenAI-compatible local endpoints. This prevents the underlying client from failing because of a missing API key/header.

  • Updated get_capabilities() to expose local provider information, including is_local and the provider family.

2. Office UI LLM settings

Added LLMModelSettingsModal.tsx and integrated it into App.tsx.

The Office UI now has an LLM settings button in the top navigation header.

The provider selector includes:

  • Ollama (Local)
  • vLLM (Local)
  • LM Studio (Local)
  • LocalAI / Llama.cpp (Local)
  • OpenAI (Cloud)
  • Anthropic Claude (Cloud)
  • OpenRouter (Cloud)
  • Custom Self-Hosted / Proxy

The settings UI also handles local and cloud providers differently:

  • Local provider endpoints are pre-filled with their default api_base.

  • API keys are optional for local providers.

  • API keys remain required for providers that need them.

  • A local-mode indicator is shown when a keyless local provider is selected.

  • Added quick-select model options for commonly used local models, including:

    • ollama/llama3.3
    • vllm/meta-llama-3.1-8b-instruct
    • lmstudio/deepseek-r1-distill-qwen-14b

3. LLM configuration schema

Updated opc/core/config.py and extended LLMConfig with:

provider: str = ""
is_local: bool = False

This allows the configuration to keep track of the selected provider and whether it is running in local mode.

4. Tests

Added:

tests/test_local_llm_provider.py

The new tests cover the local provider handling and keyless configuration behavior.

Also verified the existing LLM and CLI-related tests.

5. Documentation

Added:

docs/LOCAL_MODELS.md

and updated:

README.md
README.zh-CN.md

to document the local model setup and supported providers.

Verification

Ran the backend test suite with:

pytest tests/test_local_llm_provider.py tests/test_llm_provider_context_window.py tests/test_cli_app.py

Result:

115/115 tests passed

Ran the TypeScript check in the Office UI frontend:

tsc --noEmit

Result:

0 errors

Also rebuilt the Office UI production bundle:

npm run build

The Vite production build completed successfully and updated:

opc/plugins/office_ui/frontend_dist/

Upstream sync

Before pushing the final changes, I fetched and merged the latest upstream/main changes.

The merge introduced a conflict in the frontend bundle, which was resolved and rebuilt.

The merged changes were committed and pushed to origin/main.

I also removed the openopc-shadow-adapter directory reference from the core git index and pushed that fix.

The branch is now up to date with upstream/main and has no remaining merge conflicts.

@LZH-YS1998 LZH-YS1998 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes

Thank you for working on native local/self-hosted LLM support. Supporting Ollama, vLLM, LM Studio, LocalAI, and other OpenAI-compatible servers is valuable, but I do not think this PR is safe to merge in its current form.

There are several blocking correctness issues that can either make the advertised feature non-functional or break existing cloud-provider configurations. In affected cases, native-agent work items will fail and remain in the in-progress column with a failed status, preventing dependent work from progressing.

1. The Office UI “Save & Apply” action does not apply anything

LLMModelSettingsModal.handleSave() only writes the settings to browser localStorage and invokes an optional onSave callback:

  • opc/plugins/office_ui/frontend_src/components/LLMModelSettingsModal.tsx
  • handleSave() around lines 93–111

However, App.tsx renders the modal without providing onSave:

<LLMModelSettingsModal
  isOpen={showLLMModal}
  onClose={() => setShowLLMModal(false)}
/>

There is also no HTTP/WebSocket request that sends these settings to the backend, no update to llm_config.yaml, and no runtime LLMProvider reinitialization.

As a result:

  • Clicking “Save & Apply Settings” reports success even though the backend configuration is unchanged.
  • Selecting Ollama or another local model does not switch the model used by running work items.
  • A user without valid backend credentials can believe that a keyless local model is active while native-agent work continues to fail.
  • The README statement that the Office UI can configure the LLM is currently incorrect.

Additionally, storing API keys in localStorage is not appropriate. Any JavaScript running under the same origin can read them, and they remain in the browser profile indefinitely.

Required change

Please implement an actual backend configuration flow:

  • Load the effective backend LLM configuration when opening the modal.
  • Validate and persist changes through an appropriate backend endpoint.
  • Store secrets on the backend, not in localStorage.
  • Atomically update the configuration.
  • Recreate/reconfigure the active LLMProvider, or explicitly require and communicate a restart.
  • Return an error when validation or persistence fails instead of always showing success.
  • Add an integration test proving that saving settings changes the model used by the next request.

2. Several advertised model prefixes are not supported by the pinned LiteLLM version

The project pins:

litellm==1.82.1

I tested provider resolution against that exact installed version. The results were:

ollama/llama3.3                         -> supported
ollama_chat/qwen2.5-coder              -> supported
vllm/meta-llama-3.1-8b-instruct        -> supported
hosted_vllm/model                      -> supported

lmstudio/deepseek-r1-distill-qwen-14b  -> LLM Provider NOT provided
localai/starcoder2-15b                 -> LLM Provider NOT provided
llama-cpp/mistral-7b-instruct          -> LLM Provider NOT provided
llamacpp/model                         -> LLM Provider NOT provided
tgi/model                              -> LLM Provider NOT provided
custom-model                           -> LLM Provider NOT provided

Unfortunately, several failing formats are used as UI defaults and documented as supported:

defaultModel: 'lmstudio/deepseek-r1-distill-qwen-14b'
defaultModel: 'localai/starcoder2-15b'
defaultModel: 'custom-model'

These configurations fail before an HTTP request reaches the local server.

The new tests do not catch this because litellm.acompletion is mocked. They verify that arguments are assembled, but not that LiteLLM can resolve the advertised provider/model identifiers.

Required change

  • Use model identifiers supported by the pinned LiteLLM version.
  • For OpenAI-compatible servers, use the appropriate LiteLLM provider format together with api_base.
  • Ensure the UI, documentation, and backend all use the same formats.
  • Add non-network tests using LiteLLM’s real provider-resolution logic for every advertised provider.
  • Do not claim support for LM Studio, LocalAI, llama.cpp, or TGI until their documented configuration passes provider resolution.

3. Local-provider environment variables can misroute cloud-model requests

LLMProvider.__init__() currently resolves api_base using:

self._api_base = config.api_base or (
    os.environ.get("OLLAMA_API_BASE")
    or os.environ.get("OLLAMA_HOST")
    or os.environ.get("LOCAL_LLM_API_BASE")
    or os.environ.get("OPENAI_API_BASE")
) or None

This is applied without checking the active model/provider.

I reproduced the following configuration:

OLLAMA_HOST=http://127.0.0.1:11434
default_model=anthropic/claude-sonnet-4-20250514

The resulting provider state was:

resolved_api_base=http://127.0.0.1:11434
has_credentials=True

This means that merely having Ollama configured in the environment can cause an Anthropic request to be sent to the Ollama endpoint.

_is_local_endpoint() has the same global behavior: the presence of any local-provider environment variable makes it return True, even if the model being evaluated is a cloud model. The request path may then inject:

api_key = "local"

This can break existing cloud configurations on machines that also run a local model server.

Required change

  • Resolve environment variables only for their matching provider.
  • Do not treat the presence of a local-provider environment variable as evidence that an unrelated model is local.
  • Derive api_base, credentials, and local/keyless behavior from the model selected for the specific request.
  • Add regression tests for cloud models while OLLAMA_HOST, VLLM_API_BASE, and similar variables are present.

4. Routed models inherit the default model’s endpoint

The provider calculates _api_base once from config.default_model, while _select_model(task_type) may later choose a different model from config.routing.

For example:

llm:
  default_model: ollama/llama3.3
  routing:
    planning: anthropic/claude-sonnet-4-20250514

The routed Anthropic request still inherits the Ollama endpoint. Because the inherited endpoint is local, the request can also receive the dummy "local" API key.

The reverse configuration is similarly unsafe: a cloud default with a routed local model does not reliably resolve the local model’s endpoint.

Required change

Resolve endpoint and authentication settings per selected model/request, not once from default_model. Please add tests for both directions:

  • Local default → cloud routed model
  • Cloud default → local routed model

5. Switching from a local provider to a cloud provider preserves the local URL

The UI only updates apiBase when the selected preset has a non-empty default:

if (preset.defaultBase) setApiBase(preset.defaultBase)

Cloud presets use an empty defaultBase. Therefore:

  1. Select Ollama.
  2. api_base becomes http://localhost:11434.
  3. Select OpenAI or Anthropic.
  4. The Ollama URL remains in the form and is saved with the cloud model.

This is currently masked by the missing backend integration. Once the save flow is implemented, it will misroute cloud requests.

Required change

Always set the API base when changing provider, including clearing it for providers that use their standard endpoint.

6. Local capability reporting is overly optimistic

get_capabilities() reports tool calling, streaming tool calls, and streaming support as enabled for all models. Local models and local inference servers do not necessarily support those features.

OpenOPC’s work-item runtime depends heavily on reliable tool calling. A model that only supports text generation may appear successfully configured but fail to execute the requested work, loop without useful actions, or ultimately return a failed result.

Required change

At minimum:

  • Document the tool-calling requirement.
  • Add a connection/model validation step.
  • Avoid presenting an endpoint as fully operational based only on its hostname or model prefix.
  • Ideally verify basic completion and tool-call support before applying the configuration.

7. The PR contains unrelated and incomplete Shadow Adapter changes

The PR also changes:

  • opc/cli/app.py
  • opc/layer3_agent/adapters/registry.py
  • opc/plugins/shadow_adapter/__init__.py

These changes are unrelated to local LLM support. The plugin imports a top-level shadow_adapter package that is neither included nor declared as a dependency.

The imports are partially protected by ImportError, so the normal missing-package case may be ignored, but the feature is incomplete and expands the regression surface of this PR. An incompatible or partially installed package could also raise a non-ImportError exception during startup.

Required change

Please remove the Shadow Adapter changes from this PR and submit them separately with their implementation, dependency declaration, tests, and documentation.

Work-item impact

A provider-resolution or endpoint-routing error follows this path:

  1. The LLM stream fails.
  2. The runtime performs bounded provider-error retries.
  3. The runtime returns TaskStatus.FAILED.
  4. The engine may retry the task once according to its retry policy.
  5. The final task remains failed.
  6. Office UI keeps the task in the in-progress column with status=failed.
  7. Downstream work items that depend on its output cannot progress normally.

This normally does not leave the task permanently marked as running, but it does block the work item until a user corrects the configuration and retries it.

Verification performed

I reviewed the net diff from the current main base to PR head 183b51adf6921f6ad39ad072a3dbb0a5d43bd1c4 and ran the following checks against the PR snapshot:

  • PR-declared backend test selection: 115 passed
  • Native runtime, company collaboration, and Office UI status tests: 223 passed
  • TypeScript type check: passed
  • git diff --check: passed

These results show that the existing tests remain green, but they do not cover the blocking scenarios above. In particular, there is no real LiteLLM provider-resolution test, no UI-to-backend configuration test, and no cross-provider environment/routing test.

Decision

Request changes.

The feature is worth pursuing, and a limited Ollama/vLLM implementation could be a good first step. However, the current PR should not be merged until:

  1. The UI settings actually reach and update the backend.
  2. API keys are removed from browser localStorage.
  3. Every advertised model format works with the pinned LiteLLM version.
  4. Endpoint and credential resolution is scoped per provider and selected model.
  5. Mixed routing is tested.
  6. Local-to-cloud provider switching clears stale endpoint values.
  7. End-to-end work-item failure/recovery tests are added.
  8. The unrelated Shadow Adapter changes are removed or split into a separate PR.

@AhmadHassan-BTed

Copy link
Copy Markdown
Author

Hi @LZH-YS1998,

Thank you for the thorough and constructive code review. I've addressed all 7 blocking issues in our latest commits and merged the latest upstream/main (c283d39) into this PR.

1. Office UI "Save & Apply" Backend Configuration Flow

  • Backend Configuration API & WS: Implemented /api/llm/config (GET & POST) REST endpoints and get_llm_config / update_llm_config WebSocket handlers in server.py and ws_handler.py.
  • Atomic Persistence & Provider Hot-Reload: Saving from the Office UI modal updates .opc/config/llm_config.yaml using OPCConfig.save() and reinitializes the active engine LLMProvider dynamically.
  • Secrets Security: Completely removed API key storage from browser localStorage. Effective config is fetched from the backend on modal load and persisted on the backend.
  • Validation & Errors: Validation or persistence errors return HTTP 400/500 with error details to the UI.
  • Integration Test: Added test_backend_config_persistence_and_provider_reinitialization in tests/test_local_llm_provider.py.

2. LiteLLM Provider Resolution for Advertised Models

  • Standardized Model Identifiers: Updated defaults, UI presets, and documentation to use model formats supported by litellm==1.82.1:
    • Ollama: ollama/<model> / ollama_chat/<model>
    • vLLM: vllm/<model>
    • LM Studio / LocalAI / Llama.cpp / Custom Local: openai/<model> with api_base.
  • LiteLLM Provider Resolution Helper: Implemented _normalize_litellm_model(model, api_base) to route custom local endpoints through LiteLLM's OpenAI-compatible provider.
  • Resolution Unit Tests: Added test_litellm_provider_resolution_for_advertised_models which tests real litellm.get_llm_provider() calls for all advertised presets.

3. Provider-Scoped Environment Variables

  • Scoped Resolution: OLLAMA_HOST, VLLM_API_BASE, and LOCALAI_API_BASE checks are now scoped strictly to target model prefixes matching that provider.
  • Cloud Model Protection: Prevented local environment variables from setting api_base or injecting api_key = "local" for cloud models (anthropic/claude-sonnet-4-20250514, openai/gpt-4o).
  • Regression Test: Added test_local_env_var_does_not_misroute_cloud_models in tests/test_local_llm_provider.py.

4. Dynamic Per-Model Endpoint & Routing Resolution

  • Per-Model Resolution: Refactored LLMProvider so resolve_api_base(model) and has_credentials(model) evaluate dynamically per selected request model (_select_model(task_type)).
  • Mixed Routing Tests: Added test_routed_models_resolution_both_directions covering:
    • Local default $\rightarrow$ Cloud routed model (default_model: ollama/llama3.3, routing: { planning: anthropic/claude-sonnet-4-20250514 })
    • Cloud default $\rightarrow$ Local routed model (default_model: openai/gpt-4o, routing: { code: ollama/qwen2.5-coder })

5. Local-to-Cloud Provider Preset Switching

  • Clean URL Reset: Updated handleProviderChange in LLMModelSettingsModal.tsx to set setApiBase(preset.defaultBase || ''), clearing stale local endpoints when selecting cloud providers.

6. Local Capability Guidance

  • Updated docs/LOCAL_MODELS.md and UI modal status text to explicitly note function/tool calling requirements for local models used with native OpenOPC agents.

7. Clean PR Scope

  • Deleted opc/plugins/shadow_adapter/ from this PR.
  • Reverted opc/cli/app.py and opc/layer3_agent/adapters/registry.py to upstream/main clean state.

Verification Run

  • Backend Tests: pytest tests/test_local_llm_provider.py $\rightarrow$ 10 / 10 PASSED
  • Full Test Suite: pytest tests/ $\rightarrow$ 571 PASSED
  • TypeScript Check: tsc --noEmit $\rightarrow$ 0 ERRORS
  • Vite Build: Rebuilt production bundle in opc/plugins/office_ui/frontend_dist/

Please let me know if any further adjustments are needed!

@LZH-YS1998 LZH-YS1998 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for addressing the first review. I re-reviewed and executed the current head (3bf6c767) rather than only reading the new tests. The provider-format cleanup and removal of the unrelated shadow adapter are good, but the backend/runtime path still has release-blocking correctness and credential-isolation problems.

  1. The Office UI reads and writes the wrong configuration directory. In server.py:225-228,253-266 and the equivalent WS handler, opc_home is passed to OPCConfig.load/save, while the application starts from opc_home / "config". Also, create_app() stores engine, not app["opc_home"] or app["config"], so the fallback path is always used. I reproduced a POST returning HTTP 200 while the canonical opc_home/config/llm_config.yaml remained on the old Anthropic model/key; the handler instead created system_config.yaml, llm_config.yaml, agent_config.yaml, and channel_config.yaml directly under opc_home. This means the UI reports success but the setting is lost on restart, and a nominal LLM-only update writes unrelated config files. Please use the canonical config directory, persist only the intended LLM config under the existing config lock/service boundary, and add a real HTTP/WS handler integration test that starts with opc_home/config/llm_config.yaml and verifies that exact file.

  2. “Hot apply” does not reconfigure the running engine. server.py:268-271 only replaces engine.llm. After initializing a real OPCEngine and applying the same replacement, history_compactor, communication, approval_engine, secretary, company_runtime_spec_builder, company_recruiter, company_executor, and task_router all still referenced the old LLMProvider. Company work items can therefore mix old and new models/endpoints in one run. Project delegate engines and already-running work also need an explicit policy. Please provide one engine-level reconfiguration operation that consistently updates all consumers (with safe in-flight semantics), or make the UI honestly require a restart. The current test named test_backend_config_persistence_and_provider_reinitialization never calls a handler or initializes an engine, so it cannot validate this claim.

  3. Mixed local/cloud routing is still misrouted. resolve_api_base() returns the single explicit config.api_base before inspecting the selected routed model (provider.py:330-375), and config.is_local is likewise applied globally (provider.py:377-388,711-716). With the configuration produced by the UI—Ollama default, api_base=http://localhost:11434, is_local=true, and an Anthropic planning route—I captured the actual litellm.acompletion kwargs:

model=anthropic/claude-sonnet-4-20250514
api_base=http://localhost:11434
api_key=local

That cloud planning/approval call goes to Ollama and can block/fail the work item. The added routing test avoids both explicit UI fields and checks only resolve_api_base, not the actual request kwargs. Endpoint, key, key-env, and local/keyless state must be scoped to the selected model/provider. Please test actual acompletion kwargs in both local→cloud and cloud→local directions, including explicit bases, is_local, configured keys, and api_key_env.

  1. Stale cloud credentials can be sent to a local server. The update API treats blank and "***" only as “keep existing key”; there is no clear operation, and api_key_env is never cleared or scoped. Switching from a cloud provider to Ollama with a blank key retained cloud-secret in my reproduction, and the new runtime provider then sent that secret to http://localhost:11434. Please define explicit keep/set/clear key semantics and ensure credentials for one provider are never forwarded to another provider or a keyless local endpoint.

  2. The configuration flow is duplicated and insufficiently validated. The React UI uses REST, but a second near-identical GET/update implementation was added to WS plus unused client callbacks. Neither path shares validation or consistently uses _config_lock; model/provider/base consistency and URL/context-window ranges are not validated. Please choose one path or put both thin transports over one tested service so behavior cannot drift and concurrent full-config saves cannot overwrite unrelated changes.

  3. Please rebase onto current main and rebuild the committed frontend bundle. The current PR is DIRTY; an isolated merge has a content conflict in frontend_dist/index.html. Current main also adds workspace-authority trust/fingerprint enforcement, so an authorized UI config mutation must integrate with that mechanism rather than bypassing it via the wrong directory.

Verification performed: the PR's 10 new Python tests pass, frontend typecheck/build and contract tests pass, and 69 related provider/server/workspace-trust tests pass after allowing the two loopback-only trust tests outside the socket-restricted sandbox. Those green tests do not cover the failures above, and the current PR head has no GitHub check runs.

@AhmadHassan-BTed

Copy link
Copy Markdown
Author

Hi @LZH-YS1998,

Thanks for the detailed second review :) I went through all of the remaining points and addressed them in the latest commits. I’ve also merged/rebased the changes onto the latest upstream/main (a7fc240).

The main changes are:

1. Canonical Config Directory & Atomic File Persistence

  • Updated OPCConfig.load and save in opc/core/config.py to use resolve_config_dir(path). This makes sure config files are always read from and written to the canonical opc_home / "config" directory, e.g. .opc/config/llm_config.yaml.
  • Added OPCConfig.save_llm_config(config_dir), which only writes llm_config.yaml atomically. It does not touch system_config.yaml, agent_config.yaml, or the channel files.
  • Added test_canonical_config_dir_file_persistence to check that opc_home / "config" / "llm_config.yaml" is actually updated on disk and that no YAML files are created directly under the root opc_home.

2. Engine-Wide Reconfiguration ("Hot Apply")

  • Added OPCEngine.reconfigure_llm(new_config: LLMConfig) in opc/engine.py.
  • This re-instantiates LLMProvider, updates self.llm, and also updates all 8 components that depend on it: history_compactor, communication, approval_engine, secretary, company_runtime_spec_builder, company_recruiter, company_executor, and task_router.
  • Added test_engine_reconfigure_llm_hot_apply to make sure the new provider is propagated to all of those components.

3. Model-Scoped Endpoint & Credential Isolation (Mixed Routing Fix)

  • Refactored resolve_api_base(model) and resolve_credentials(model) in opc/llm/provider.py so the API base and credentials are decided based only on the model actually being requested by _select_model(task_type).
  • Cloud models such as anthropic/, openrouter/, and gemini/ now ignore the local config.api_base (http://localhost:11434), config.is_local, and dummy "local" API keys.
  • Added test_acompletion_kwargs_mixed_routing_isolation covering the Ollama-default + Anthropic-planning case. It verifies that litellm.acompletion gets model="anthropic/claude-sonnet-4-20250514" with the correct api_key=sk-ant-..., and does not get api_base=http://localhost:11434.

4. Explicit Key Semantics & Credential Leak Guard

  • Defined the key handling explicitly: "" clears config.llm.api_key, while "***" keeps the existing key.
  • Keyless local endpoints now automatically use api_key = "local" and will never receive a cloud API key.
  • Added test_explicit_key_clearing_semantics for this behavior.

5. Transport Deduplication (llm_config_service.py)

  • Added the shared service opc/plugins/office_ui/llm_config_service.py, with get_llm_config_service and update_llm_config_service.
  • Both the REST endpoint (/api/llm/config) in server.py and the WebSocket action in ws_handler.py now go through this same service.
  • The service handles the thread-safe _CONFIG_LOCK, validates URL schemes (http/https), and validates the context-window range (0 to 2,000,000).

6. Upstream Sync & Rebuilt Frontend Bundle

  • Merged the latest upstream/main (a7fc240) into main.
  • Rebuilt the Vite production bundle in opc/plugins/office_ui/frontend_dist/.

Verification

  • Local Provider Suite: pytest tests/test_local_llm_provider.py13 PASSED
  • Trust & Security Sinks Suite: pytest tests/test_workspace_trust_sinks.py2 PASSED
  • Vite Build: Production bundle rebuilt cleanly.

That should cover all of the points from the second review. Please let me know if you spot anything else that needs to be adjusted.

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.

2 participants