Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 13 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -611,29 +611,27 @@ The parent manager receives a capability summary compiled from the covered roles
| `.opc/config/company_orgs/org_<id>_config.yaml` | Saved custom company architectures used by Company Mode. |
| `.opc/config/org_index.yaml` | Active saved company architecture selector. |

### LLM Keys
### LLM Keys & Local Model Support

After `opc init`, edit `.opc/config/llm_config.yaml` in the repo-local OPC home. If you set `OPC_HOME`, edit `$OPC_HOME/config/llm_config.yaml` instead.

The template leaves secrets empty. Write your key directly into the file:
After `opc init`, edit `.opc/config/llm_config.yaml` in the repo-local OPC home (or `$OPC_HOME/config/llm_config.yaml`). OpenOPC supports both Cloud LLMs (OpenAI, Anthropic, OpenRouter) and keyless **Local LLMs** (Ollama, vLLM, LM Studio, LocalAI, Llama.cpp, TGI):

```yaml
llm:
default_model: "openai/gpt-5.4"
api_base: "https://openrouter.ai/api/v1"
api_key: "sk-or-v1-..." # your OpenRouter (or other provider) API key

max_tokens: 32768 # max output tokens per request; lower it if your
# model's output cap is smaller
# context_window: 128000 # total input window. Usually auto-detected via
# litellm; unmapped models fall back to 128000.
# Uncomment and set only when the fallback is
# wrong for your model.
default_model: "ollama/llama3.3" # or "vllm/meta-llama-3.1-8b-instruct", "openai/gpt-4o"
api_base: "http://localhost:11434" # auto-resolved for local model prefixes
api_key: "" # keyless for local self-hosted nodes!

max_tokens: 32768 # max output tokens per request
is_local: true # optional explicit local execution flag
```

You can also configure LLM settings directly from the browser using the **LLM & Local Model Settings Modal (`🤖` button)** in the top header of Office UI.

See [`docs/LOCAL_MODELS.md`](docs/LOCAL_MODELS.md) for detailed setup guides on Ollama, vLLM, LM Studio, and LocalAI.

Then verify with `opc status`.

If you prefer not to store the key in the file, leave `api_key` empty and set `api_key_env` to the name of an environment variable that holds it (e.g. `api_key_env: "OPENROUTER_API_KEY"`).
If you prefer not to store cloud keys in the file, leave `api_key` empty and set `api_key_env` to the name of an environment variable that holds it (e.g. `api_key_env: "OPENROUTER_API_KEY"`).

### Approval & Agent Permissions

Expand Down
27 changes: 13 additions & 14 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -611,28 +611,27 @@ opc exec --mode task --agent jiuwenswarm "由一个 Team 完成这个任务"
| `.opc/config/company_orgs/org_<id>_config.yaml` | Company 模式使用的自定义公司架构。 |
| `.opc/config/org_index.yaml` | 当前生效的已保存公司架构选择器。 |

### LLM 密钥
### LLM 密钥与本地模型支持

运行 `opc init` 后,编辑仓库本地 OPC home 中的 `.opc/config/llm_config.yaml`。如果设置了 `OPC_HOME`,则改为编辑 `$OPC_HOME/config/llm_config.yaml`。

模板中的密钥留空。直接把 key 写入文件:
运行 `opc init` 后,编辑仓库本地 OPC home 中的 `.opc/config/llm_config.yaml`(或 `$OPC_HOME/config/llm_config.yaml`)。OpenOPC 同时支持云端 LLM(OpenAI、Anthropic、OpenRouter)与无 API Key 的**本地模型**(Ollama、vLLM、LM Studio、LocalAI、Llama.cpp、TGI):

```yaml
llm:
default_model: "openai/gpt-5.4"
api_base: "https://openrouter.ai/api/v1"
api_key: "sk-or-v1-..." # 你的 OpenRouter(或其他提供方)API key

max_tokens: 32768 # 每次请求的最大输出 token;如果你的模型
# 输出上限更小,请调低
# context_window: 128000 # 总输入窗口。通常由 litellm 自动检测;
# 未收录的模型回退为 128000。仅当回退值
# 不适合你的模型时才取消注释并设置。
default_model: "ollama/llama3.3" # 或 "vllm/meta-llama-3.1-8b-instruct"、"openai/gpt-4o"
api_base: "http://localhost:11434" # 本地模型前缀将自动解析默认 Base URL
api_key: "" # 本地节点免 API Key 运行!

max_tokens: 32768 # 每次请求的最大输出 token
is_local: true # 可选的显式本地运行标志
```

你也可以直接在浏览器 Office UI 顶栏点击 **LLM & 本地模型设置弹窗(`🤖` 按钮)** 进行可视配置。

详细配置指南请参见 [`docs/LOCAL_MODELS.md`](docs/LOCAL_MODELS.md)。

然后用 `opc status` 验证。

如果不想把密钥存在文件里,可以将 `api_key` 留空,并把 `api_key_env` 设置为持有密钥的环境变量名(例如 `api_key_env: "OPENROUTER_API_KEY"`)。
如果不想把云端密钥存在文件里,可以将 `api_key` 留空,并把 `api_key_env` 设置为持有密钥的环境变量名(例如 `api_key_env: "OPENROUTER_API_KEY"`)。

### 审批与 Agent 权限

Expand Down
64 changes: 64 additions & 0 deletions docs/LOCAL_MODELS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Running OpenOPC with Local LLM Models

OpenOPC natively supports self-hosted local LLMs (Ollama, vLLM, LM Studio, LocalAI, Llama.cpp, TGI) without requiring remote API keys.

---

## 🚀 Supported Local Providers & Formats

| Local Provider | Default Port | Model Prefix Format | Example Model Identifier |
|---|---|---|---|
| **Ollama** | `http://localhost:11434` | `ollama/<model>` | `ollama/llama3.3`, `ollama/qwen2.5-coder` |
| **vLLM** | `http://localhost:8000/v1` | `vllm/<model>` or `openai/<model>` | `vllm/meta-llama-3.1-8b-instruct` |
| **LM Studio** | `http://localhost:1234/v1` | `openai/<model>` | `openai/deepseek-r1-distill-qwen-14b` |
| **LocalAI / Llama.cpp** | `http://localhost:8080/v1` | `openai/<model>` | `openai/starcoder2-15b` |
| **Custom Local OpenAI Server** | `http://<host>:<port>/v1` | `openai/<model>` | `openai/custom-model` |

> ⚠️ **Important Requirement**: Native OpenOPC agents require a local model that supports **tool calling / function calling** (such as `ollama/llama3.3` or `ollama/qwen2.5-coder`) to execute multi-step work items cleanly.

---

## ⚡ Quick Start: Running with Ollama

1. **Start Ollama** locally and pull your model:
```bash
ollama run llama3.3
```

2. **Configure OpenOPC `llm_config.yaml`**:
Edit `~/.opc/config/llm_config.yaml`:
```yaml
llm:
default_model: "ollama/llama3.3"
api_base: "http://localhost:11434"
is_local: true
```

3. **Launch OpenOPC Session**:
```bash
opc chat "Build a REST API in Python"
```

---

## 🖥️ Running with LM Studio / LocalAI / OpenAI-Compatible Servers

For OpenAI-compatible local servers (like LM Studio or LocalAI), use `openai/<model>` together with `api_base`:

```yaml
llm:
default_model: "openai/deepseek-r1-distill-qwen-14b"
api_base: "http://localhost:1234/v1"
is_local: true
```

---

## 🌐 Office UI Settings Modal Integration

Settings can be changed dynamically from the browser using the **LLM & Local Model Settings Modal (`🤖` button)** in the top header of Office UI:

1. Click the **`🤖` button** in the header.
2. Select your provider (Ollama, vLLM, LM Studio, LocalAI, OpenAI, Anthropic).
3. Enter model identifier and API base URL.
4. Click **Save & Apply Settings**. Changes are saved atomically to `.opc/config/llm_config.yaml` and reinitialize the runtime LLMProvider instantly.
66 changes: 61 additions & 5 deletions opc/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@ class LLMConfig(BaseModel):
api_base: str = ""
api_key: str = ""
api_key_env: str = ""
provider: str = ""
is_local: bool = False
routing: dict[str, str] = Field(default_factory=dict)
fallback: dict[str, Any] = Field(default_factory=dict)
temperature: float = 0.3
Expand Down Expand Up @@ -1739,6 +1741,55 @@ def load_active_company_org_payload(config_dir: Path) -> tuple[dict[str, Any], P
return _validate_company_org_payload(path, payload), path


class OPCConfig(BaseModel):
system: SystemConfig = Field(default_factory=SystemConfig)
llm: LLMConfig = Field(default_factory=LLMConfig)
agents: AgentsConfig = Field(default_factory=AgentsConfig)
org: OrgConfig = Field(default_factory=OrgConfig)
channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
autonomy: AutonomyConfig = Field(default_factory=AutonomyConfig)
capabilities: CapabilityConfig = Field(default_factory=CapabilityConfig)
_trusted_workspace: Path | None = PrivateAttr(default=None)
_trusted_config_dir: Path | None = PrivateAttr(default=None)

def bind_workspace_trust(self, workspace: Path, config_dir: Path) -> None:
"""Attach the project trust provenance used for pre-sink rechecks."""

self._trusted_workspace = Path(workspace).resolve(strict=False)
self._trusted_config_dir = Path(config_dir).resolve(strict=False)

def require_workspace_trust(self, *, include_effective: bool = False) -> None:
"""Revalidate bound project authority immediately before runtime use.

Normal runtime checks bind the source files while allowing explicit
host-side CLI overrides such as ``--model`` and ``--debug``. Loading
and trust-grant flows additionally validate the normalized declaration.
"""

if self._trusted_workspace is None or self._trusted_config_dir is None:
return
from opc.core.workspace_trust import WorkspaceTrustStore

WorkspaceTrustStore().require(
self._trusted_workspace,
self._trusted_config_dir,
self if include_effective else None,
)

def resolve_config_dir(path: Path | None = None) -> Path:
"""Resolve target config directory (canonical: opc_home / 'config')."""
if path is None:
return get_opc_home() / "config"
p = Path(path)
if p.name == "config":
return p
if (p / "config").is_dir():
return p / "config"
if not (p / "llm_config.yaml").exists() and not (p / "system_config.yaml").exists():
return p / "config"
return p


class OPCConfig(BaseModel):
system: SystemConfig = Field(default_factory=SystemConfig)
llm: LLMConfig = Field(default_factory=LLMConfig)
Expand Down Expand Up @@ -1781,9 +1832,7 @@ def load(
*,
trusted_source: bool = False,
) -> "OPCConfig":
if config_dir is None:
config_dir = get_opc_home() / "config"
config_dir = Path(config_dir)
config_dir = resolve_config_dir(config_dir)
trusted_workspace: Path | None = None
trust_store: Any | None = None

Expand Down Expand Up @@ -1872,9 +1921,16 @@ def load(
config.bind_workspace_trust(trusted_workspace, config_dir)
return config

def save_llm_config(self, config_dir: Path | None = None) -> Path:
"""Atomically save ONLY llm_config.yaml under canonical config_dir."""
target_dir = resolve_config_dir(config_dir)
target_dir.mkdir(parents=True, exist_ok=True)
llm_path = target_dir / "llm_config.yaml"
_atomic_write_yaml(llm_path, {"llm": self.llm.model_dump()})
return llm_path

def save(self, config_dir: Path | None = None) -> None:
if config_dir is None:
config_dir = get_opc_home() / "config"
config_dir = resolve_config_dir(config_dir)
config_dir.mkdir(parents=True, exist_ok=True)

system_path = config_dir / "system_config.yaml"
Expand Down
24 changes: 24 additions & 0 deletions opc/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,30 @@ async def initialize(self) -> None:
await self._recover_interaction_consumers()
await self._recover_company_work_item_gate_continuations()
logger.info("OPC Engine initialized successfully")

def reconfigure_llm(self, new_config: LLMConfig) -> LLMProvider:
"""Consistently reconfigure LLMProvider for the engine and all dependent consumers."""
self.config.llm = new_config
new_llm = LLMProvider(new_config, opc_home=self.opc_home)
self.llm = new_llm

if getattr(self, "history_compactor", None) is not None:
self.history_compactor.llm = new_llm
if getattr(self, "communication", None) is not None:
self.communication.llm = new_llm
if getattr(self, "approval_engine", None) is not None:
self.approval_engine.llm = new_llm
if getattr(self, "secretary", None) is not None:
self.secretary.llm = new_llm
if getattr(self, "company_runtime_spec_builder", None) is not None:
self.company_runtime_spec_builder.llm = new_llm
if getattr(self, "company_recruiter", None) is not None:
self.company_recruiter.llm = new_llm
if getattr(self, "company_executor", None) is not None:
self.company_executor.llm = new_llm
if getattr(self, "task_router", None) is not None:
self.task_router.llm = new_llm
return new_llm
if reconciled:
logger.warning(
"Reconciled {} interrupted task(s) for project {} during startup",
Expand Down
Loading