Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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 @@ -559,29 +559,27 @@ Run `opc init` once from the repo root. It creates `.opc/`, copies the template
| `.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 @@ -553,28 +553,27 @@ opc session create "Research sprint" -p demo --mode org --org hku_research_lab
| `.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
72 changes: 72 additions & 0 deletions docs/LOCAL_MODELS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# 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 Name |
|---|---|---|---|
| **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` | `lmstudio/<model>` or `openai/<model>` | `lmstudio/deepseek-r1-distill-qwen-14b` |
| **LocalAI** | `http://localhost:8080/v1` | `localai/<model>` | `localai/starcoder2-15b` |
| **Llama.cpp** | `http://localhost:8080/v1` | `llama-cpp/<model>` | `llama-cpp/mistral-7b-instruct` |

---

## ⚡ 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"
```

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

---

## 🖥️ Running with vLLM / LM Studio / LocalAI

OpenAI-compatible local servers (like vLLM or LM Studio) can be used by setting `api_base`:

```yaml
llm:
default_model: "vllm/meta-llama-3.1-8b-instruct"
api_base: "http://localhost:8000/v1"
```

Or via environment variables:

```bash
export OLLAMA_HOST="http://localhost:11434"
# or
export LOCAL_LLM_API_BASE="http://localhost:8000/v1"
```

---

## 🔧 Explicit `is_local` Flag

If your local server uses a non-standard port or hostname, mark `is_local: true` in `llm_config.yaml`:

```yaml
llm:
default_model: "my-custom-local-model"
api_base: "http://192.168.1.150:9000/v1"
is_local: true
```

This instructs OpenOPC's LLM provider layer to skip remote credential validation and execute native AI agents using your local workstation.
6 changes: 6 additions & 0 deletions opc/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8375,6 +8375,12 @@ async def _run_channel_runtime(config, project: str | None) -> None:
except ImportError:
pass

try:
from opc.plugins.shadow_adapter import register_cli_commands as register_shadow_cli
register_shadow_cli(app)
except ImportError:
pass


def main():
keylog_path = pop_windows_sslkeylogfile()
Expand Down
2 changes: 2 additions & 0 deletions opc/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,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
11 changes: 11 additions & 0 deletions opc/layer3_agent/adapters/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@
"opencode": OpenCodeAdapter,
}

# Auto-register ShadowModeAdapter if installed
try:
from shadow_adapter.adapter import ShadowModeAdapter
ADAPTER_CLASSES["shadow"] = ShadowModeAdapter
except ImportError:
try:
from opc.plugins.shadow_adapter.adapter import ShadowModeAdapter
ADAPTER_CLASSES["shadow"] = ShadowModeAdapter
except ImportError:
pass


class AdapterRegistry:
"""Manages external agent adapters and preferred order."""
Expand Down
95 changes: 87 additions & 8 deletions opc/llm/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,52 @@ def _is_poe_base(api_base: str | None) -> bool:
return hostname == "api.poe.com"


_LOCAL_MODEL_PREFIXES = (
"ollama/",
"ollama_chat/",
"localai/",
"vllm/",
"hosted_vllm/",
"lmstudio/",
"llama-cpp/",
"llamacpp/",
"tgi/",
)

_LOCAL_HOSTNAME_HINTS = (
"localhost",
"127.0.0.1",
"0.0.0.0",
"::1",
)


def _is_local_endpoint(api_base: str | None = None, model: str | None = None) -> bool:
"""True when target api_base or model represents a self-hosted / local LLM node."""
if model:
normalized_model = model.strip().lower()
if any(normalized_model.startswith(prefix) for prefix in _LOCAL_MODEL_PREFIXES):
return True

if api_base:
normalized_base = api_base.strip().lower()
try:
parsed = urlparse(normalized_base)
hostname = (parsed.hostname or "").strip().lower()
if hostname in _LOCAL_HOSTNAME_HINTS or hostname.endswith(".local"):
return True
except Exception:
pass

if any(
os.environ.get(var)
for var in ("OLLAMA_HOST", "OLLAMA_API_BASE", "LOCAL_LLM_API_BASE", "VLLM_API_BASE", "LOCALAI_API_BASE")
):
return True

return False


def _looks_like_multimodal_model(model: str) -> bool:
normalized = _normalized_model_name(model)
if any(hint in normalized for hint in _MULTIMODAL_MODEL_HINTS):
Expand Down Expand Up @@ -247,20 +293,39 @@ def __init__(self, config: LLMConfig, opc_home: Path | None = None) -> None:
self._api_key = config.api_key or (
os.environ.get(config.api_key_env) if config.api_key_env else None
) or None
self._api_base = config.api_base or None
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

# Auto-resolve default local api_base if model prefix implies a local provider and no api_base was set
default_model = (config.default_model or "").lower()
if not self._api_base:
if default_model.startswith("ollama/") or default_model.startswith("ollama_chat/"):
self._api_base = "http://localhost:11434"
elif default_model.startswith("vllm/") or default_model.startswith("hosted_vllm/"):
self._api_base = "http://localhost:8000/v1"
elif default_model.startswith("localai/") or default_model.startswith("llamacpp/"):
self._api_base = "http://localhost:8080/v1"
elif default_model.startswith("lmstudio/"):
self._api_base = "http://localhost:1234/v1"

def has_credentials(self) -> bool:
"""Whether an LLM call can plausibly authenticate.

True when a key is configured (``api_key`` / ``api_key_env``) or a
well-known provider env var is present. False only when no credential
is found anywhere — callers use that to skip LLM work that would
certainly fail (e.g. native agent selection when an external agent can
run the task instead). A False at worst degrades to rule-based behavior,
which stays functional; it never blocks execution.
True when a key is configured (``api_key`` / ``api_key_env``), a
well-known provider env var is present, OR when target model/api_base is a
self-hosted / local LLM endpoint (Ollama, LocalAI, vLLM, LM Studio, Llama.cpp)
that does not require authentication.
"""
if self._api_key:
return True
if getattr(self.config, "is_local", False):
return True
if _is_local_endpoint(self._api_base, self.config.default_model):
return True
return any(os.environ.get(var) for var in self._CREDENTIAL_ENV_VARS)

@property
Expand Down Expand Up @@ -376,7 +441,16 @@ def get_capabilities(
resolved_model = model or self._select_model(task_type)
normalized = _normalized_model_name(resolved_model)
provider_family = resolved_model.split("/", 1)[0].strip().lower() if "/" in resolved_model else ""
supports_thinking = any(hint in normalized for hint in ("o1", "o3", "o4", "gpt-5", "claude", "reason"))
if not provider_family and getattr(self.config, "provider", ""):
provider_family = self.config.provider.strip().lower()
if not provider_family and _is_local_endpoint(self._api_base, resolved_model):
provider_family = "local"

is_local = (
getattr(self.config, "is_local", False)
or _is_local_endpoint(self._api_base, resolved_model)
)
supports_thinking = any(hint in normalized for hint in ("o1", "o3", "o4", "gpt-5", "claude", "reason", "r1"))
return ModelCapabilitySet(
model=resolved_model,
supports_streaming=True,
Expand All @@ -389,6 +463,7 @@ def get_capabilities(
provider_family=provider_family,
metadata={
"api_base": self._api_base or "",
"is_local": is_local,
},
)

Expand Down Expand Up @@ -575,6 +650,8 @@ async def chat(
call_kwargs["api_base"] = self._api_base
if self._api_key:
call_kwargs["api_key"] = self._api_key
elif _is_local_endpoint(self._api_base, model) or getattr(self.config, "is_local", False):
call_kwargs["api_key"] = "local"
if tools:
call_kwargs["tools"] = tools
call_kwargs["tool_choice"] = "auto"
Expand Down Expand Up @@ -723,6 +800,8 @@ async def chat_stream(
call_kwargs["api_base"] = self._api_base
if self._api_key:
call_kwargs["api_key"] = self._api_key
elif _is_local_endpoint(self._api_base, model) or getattr(self.config, "is_local", False):
call_kwargs["api_key"] = "local"
if tools:
call_kwargs["tools"] = tools
call_kwargs["tool_choice"] = "auto"
Expand Down

Large diffs are not rendered by default.

28 changes: 14 additions & 14 deletions opc/plugins/office_ui/frontend_dist/index.html
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>OpenOPC Pixel Office</title>
<script type="module" crossorigin src="./assets/index-Cdi_JV6w.js"></script>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," />
<title>OpenOPC Pixel Office</title>
<script type="module" crossorigin src="./assets/index-CtWkL33x.js"></script>
<link rel="modulepreload" crossorigin href="./assets/phaser-DFK5Ua9d.js">
<link rel="stylesheet" crossorigin href="./assets/index-B6-ikSHW.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
</head>
<body>
<div id="root"></div>
</body>
</html>

Loading