Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "onemancompany"
version = "0.7.77"
version = "0.7.78"
description = "A one-man company simulation with pixel art visualization and LangChain AI agents"
requires-python = ">=3.12"
dependencies = [
Expand Down
16 changes: 11 additions & 5 deletions src/onemancompany/agents/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ def make_llm(employee_id: str = "", temperature: float | None = None) -> BaseCha
api_provider = settings.default_api_provider or "openrouter"
api_key = ""
auth_method = ""
employee_api_base_url = ""
employee_custom_chat_class = ""

if employee_id and employee_id in employee_configs:
cfg = employee_configs[employee_id]
Expand All @@ -171,6 +173,8 @@ def make_llm(employee_id: str = "", temperature: float | None = None) -> BaseCha
api_provider = cfg.api_provider
api_key = cfg.api_key
auth_method = cfg.auth_method
employee_api_base_url = cfg.api_base_url
employee_custom_chat_class = cfg.custom_chat_class

if temperature is not None:
effective_temp = temperature
Expand All @@ -197,8 +201,8 @@ def make_llm(employee_id: str = "", temperature: float | None = None) -> BaseCha

# For custom provider, override chat_class from runtime settings
effective_chat_class = prov.chat_class if prov else CHAT_CLASS_OPENAI
if api_provider == "custom" and settings.custom_chat_class:
effective_chat_class = settings.custom_chat_class
if api_provider == "custom":
effective_chat_class = employee_custom_chat_class or settings.custom_chat_class or effective_chat_class

# --- Anthropic (non-OpenAI-compatible) ---
if prov and effective_chat_class == CHAT_CLASS_ANTHROPIC:
Expand All @@ -218,8 +222,8 @@ def make_llm(employee_id: str = "", temperature: float | None = None) -> BaseCha
if auth_method == AuthMethod.OAUTH or effective_key.startswith("sk-ant-oat"):
extra_headers["anthropic-beta"] = "oauth-2025-04-20"
base_url = None
if api_provider == "custom" and settings.default_api_base_url:
base_url = settings.default_api_base_url
if api_provider == "custom":
base_url = employee_api_base_url or settings.default_api_base_url
return ChatAnthropic(
model=model,
api_key=effective_key,
Expand All @@ -238,7 +242,9 @@ def make_llm(employee_id: str = "", temperature: float | None = None) -> BaseCha
# Allow custom base_url override: provider-specific or global
if api_provider == "openrouter":
base_url = settings.openrouter_base_url
elif api_provider == "custom" or (settings.default_api_base_url and api_provider == settings.default_api_provider):
elif api_provider == "custom":
base_url = employee_api_base_url or settings.default_api_base_url
elif settings.default_api_base_url and api_provider == settings.default_api_provider:
base_url = settings.default_api_base_url
extra_body = None
if (api_provider or "").lower() == "deepseek":
Expand Down
6 changes: 4 additions & 2 deletions src/onemancompany/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,8 @@ async def apply_auth(body: dict) -> dict:
api_key=body.get("api_key", ""),
model=body.get("model", ""),
employee_id=body.get("employee_id", ""),
base_url=body.get("base_url", ""),
chat_class=body.get("chat_class", ""),
base_url=body.get("base_url"),
chat_class=body.get("chat_class"),
)


Expand Down Expand Up @@ -1414,6 +1414,8 @@ async def get_employee_detail(employee_id: str) -> dict:
result["api_provider"] = api_provider
result["api_key_set"] = bool(api_key)
result["api_key_preview"] = ("..." + api_key[-4:]) if len(api_key) >= 4 else ""
result["api_base_url"] = cfg.api_base_url if cfg else ""
result["custom_chat_class"] = cfg.custom_chat_class if cfg else ""
result["hosting"] = cfg.hosting if cfg else HostingMode.COMPANY.value
result["auth_method"] = cfg.auth_method if cfg else "api_key"
# Self-hosted employees manage their own auth via Claude CLI — always considered logged in
Expand Down
6 changes: 4 additions & 2 deletions src/onemancompany/core/auth_apply/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ async def apply_auth_choice(
api_key: str = "",
model: str = "",
employee_id: str = "",
base_url: str = "",
chat_class: str = "",
base_url: str | None = None,
chat_class: str | None = None,
) -> dict:
"""Dispatch an auth choice to the appropriate apply handler.

Expand Down Expand Up @@ -62,6 +62,8 @@ async def apply_auth_choice(
employee_id=employee_id,
api_key=api_key,
model=model,
base_url=base_url,
chat_class=chat_class,
)
else:
return {"error": f"Invalid scope: {scope}", "code": "invalid_scope"}
Expand Down
15 changes: 13 additions & 2 deletions src/onemancompany/core/auth_apply/api_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ async def apply_api_key_company(
provider: str,
api_key: str,
model: str = "",
base_url: str = "",
chat_class: str = "",
base_url: str | None = None,
chat_class: str | None = None,
) -> dict:
"""Apply an API key at the company level (Settings/.env).

Expand Down Expand Up @@ -53,9 +53,12 @@ async def apply_api_key_employee(
employee_id: str,
api_key: str,
model: str = "",
base_url: str | None = None,
chat_class: str | None = None,
) -> dict:
"""Apply an API key at the employee level (profile.yaml)."""
from onemancompany.api.routes import _rebuild_employee_agent
from onemancompany.core.config import CHAT_CLASS_ANTHROPIC, CHAT_CLASS_OPENAI

update_data: dict = {
"api_provider": provider,
Expand All @@ -64,6 +67,14 @@ async def apply_api_key_employee(
}
if model:
update_data["llm_model"] = model
if provider == "custom":
valid_chat_classes = {CHAT_CLASS_OPENAI, CHAT_CLASS_ANTHROPIC}
if chat_class and chat_class not in valid_chat_classes:
return {"error": "Invalid chat_class", "code": "invalid_chat_class"}
if base_url is not None:
update_data["api_base_url"] = base_url
if chat_class is not None:
update_data["custom_chat_class"] = chat_class

await _store.save_employee(employee_id, update_data)
_rebuild_employee_agent(employee_id)
Expand Down
2 changes: 2 additions & 0 deletions src/onemancompany/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,8 @@ class EmployeeConfig(BaseModel):
onboarding_completed: bool = False # set True after onboarding routine
api_provider: str = "openrouter" # provider name from PROVIDER_REGISTRY
api_key: str = "" # Custom API key (used when api_provider != default)
api_base_url: str = "" # Optional per-employee base URL override (primarily for custom provider)
custom_chat_class: str = "" # Optional per-employee chat class override ("openai" | "anthropic")
hosting: str = "company" # "company" | "self" | "openclaw" — also serves as agent family selector
auth_method: str = "api_key" # "api_key" | "oauth" (OAuth PKCE for Anthropic)
oauth_refresh_token: str = "" # OAuth refresh token (long-lived)
Expand Down
49 changes: 49 additions & 0 deletions tests/unit/agents/test_base_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,55 @@ def test_custom_base_url_override(self, monkeypatch):
llm = make_llm()
assert llm is not None

def test_employee_custom_provider_overrides(self, monkeypatch):
from onemancompany.agents.base import make_llm, CHAT_CLASS_OPENAI, CHAT_CLASS_ANTHROPIC

mock_settings = MagicMock()
mock_settings.default_llm_model = "gpt-4"
mock_settings.default_api_provider = "openrouter"
mock_settings.custom_chat_class = CHAT_CLASS_OPENAI
mock_settings.default_api_base_url = "https://company.example/v1"
mock_settings.openrouter_api_key = "sk-or"
mock_settings.openrouter_base_url = "https://openrouter.ai/api/v1"
mock_settings.anthropic_auth_method = "api_key"
mock_settings.anthropic_oauth_token = ""
mock_settings.anthropic_api_key = ""

mock_cfg = MagicMock()
mock_cfg.llm_model = "claude-3-5-sonnet"
mock_cfg.temperature = 0.2
mock_cfg.api_provider = "custom"
mock_cfg.api_key = "sk-custom"
mock_cfg.auth_method = "api_key"
mock_cfg.api_base_url = "https://employee.example/v1"
mock_cfg.custom_chat_class = CHAT_CLASS_ANTHROPIC

captured = {}

def _fake_chat_anthropic(**kwargs):
captured.update(kwargs)
return MagicMock()

mock_prov = MagicMock()
mock_prov.chat_class = CHAT_CLASS_OPENAI
mock_prov.env_key = "custom_api_key"
mock_prov.base_url = ""

monkeypatch.setattr("onemancompany.agents.base._cfg.settings", mock_settings)
monkeypatch.setattr("onemancompany.agents.base.employee_configs", {"00010": mock_cfg})
monkeypatch.setattr(
"onemancompany.agents.base._cfg.normalize_llm_profile_defaults",
lambda profile, reason: False,
)
with patch("onemancompany.agents.base.get_provider", return_value=mock_prov), \
patch("langchain_anthropic.ChatAnthropic", side_effect=_fake_chat_anthropic), \
patch("onemancompany.agents.base.ChatOpenAI") as mock_openai:
llm = make_llm("00010")

assert llm is not None
assert captured["base_url"] == "https://employee.example/v1"
mock_openai.assert_not_called()

def test_fallback_no_key_warning(self, monkeypatch):
"""Missing selected-provider key and fallback key still creates a deferred-failure LLM."""
from onemancompany.agents.base import make_llm
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/core/test_auth_apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,64 @@ async def test_apply_employee_no_model_keeps_existing(self):
saved_data = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("data", {})
assert "llm_model" not in saved_data

async def test_apply_employee_saves_custom_endpoint_fields(self):
from onemancompany.core.auth_apply.api_key import apply_api_key_employee

mock_store = AsyncMock()

with patch("onemancompany.core.auth_apply.api_key._store", mock_store), \
patch("onemancompany.api.routes._rebuild_employee_agent"):
result = await apply_api_key_employee(
provider="custom",
employee_id="00010",
api_key="sk-test-key",
base_url="https://llm.example.com/v1",
chat_class="openai",
)

assert result["status"] == "applied"
call_args = mock_store.save_employee.call_args
saved_data = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("data", {})
assert saved_data["api_base_url"] == "https://llm.example.com/v1"
assert saved_data["custom_chat_class"] == "openai"

async def test_apply_employee_rejects_invalid_chat_class(self):
from onemancompany.core.auth_apply.api_key import apply_api_key_employee

mock_store = AsyncMock()

with patch("onemancompany.core.auth_apply.api_key._store", mock_store), \
patch("onemancompany.api.routes._rebuild_employee_agent"):
result = await apply_api_key_employee(
provider="custom",
employee_id="00010",
api_key="sk-test-key",
base_url="https://llm.example.com/v1",
chat_class="invalid",
)

assert result["code"] == "invalid_chat_class"
mock_store.save_employee.assert_not_called()

async def test_apply_employee_omits_unspecified_endpoint_fields(self):
from onemancompany.core.auth_apply.api_key import apply_api_key_employee

mock_store = AsyncMock()

with patch("onemancompany.core.auth_apply.api_key._store", mock_store), \
patch("onemancompany.api.routes._rebuild_employee_agent"):
result = await apply_api_key_employee(
provider="custom",
employee_id="00010",
api_key="sk-test-key",
)

assert result["status"] == "applied"
call_args = mock_store.save_employee.call_args
saved_data = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("data", {})
assert "api_base_url" not in saved_data
assert "custom_chat_class" not in saved_data


class TestApplyApiKeyCompanyEdgeCases:
async def test_unknown_provider_returns_error(self):
Expand Down