Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
154 lines
6.4 KiB
Python
154 lines
6.4 KiB
Python
"""Unit tests for OpenClaw adapter env-var key selection and provider URL routing.
|
|
|
|
The key-selection and URL-routing logic lives inline in OpenClawAdapter.setup()
|
|
(adapter.py lines 84-92). Since setup() carries heavy subprocess dependencies,
|
|
these tests isolate the selection logic by reproducing the exact Python expressions
|
|
from the adapter source — if the adapter's logic changes, these tests must be kept
|
|
in sync.
|
|
|
|
Organisation:
|
|
TestEnvKeyChain — priority order of the 3 currently supported keys
|
|
TestProviderUrlMapping — model-prefix → provider URL dict correctness
|
|
TestNegativeAndFallback — no keys set / unsupported keys
|
|
xfail stubs — AISTUDIO + QIANFAN documented as not-yet-implemented
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers — mirror the exact expressions from adapter.py lines 84-92.
|
|
# Must be kept in sync with the adapter source.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _select_key(env: dict) -> str:
|
|
"""Mirror line 84: nested os.environ.get priority chain."""
|
|
return env.get("OPENAI_API_KEY",
|
|
env.get("GROQ_API_KEY",
|
|
env.get("OPENROUTER_API_KEY", "")))
|
|
|
|
|
|
_PROVIDER_URLS: dict[str, str] = {
|
|
"openai": "https://api.openai.com/v1",
|
|
"groq": "https://api.groq.com/openai/v1",
|
|
"openrouter": "https://openrouter.ai/api/v1",
|
|
}
|
|
|
|
|
|
def _select_url(model: str, runtime_config: dict | None = None) -> str:
|
|
"""Mirror lines 86-92: model-prefix → provider URL with optional override."""
|
|
prefix = model.split(":")[0] if ":" in model else "openai"
|
|
return (runtime_config or {}).get(
|
|
"provider_url",
|
|
_PROVIDER_URLS.get(prefix, "https://api.openai.com/v1"),
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Env-var key priority chain (3 keys currently in adapter.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestEnvKeyChain:
|
|
|
|
def test_openai_key_selected(self):
|
|
with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-openai-test"}, clear=True):
|
|
assert _select_key(os.environ) == "sk-openai-test"
|
|
|
|
def test_groq_key_selected_when_openai_absent(self):
|
|
with patch.dict(os.environ, {"GROQ_API_KEY": "sk-groq-test"}, clear=True):
|
|
assert _select_key(os.environ) == "sk-groq-test"
|
|
|
|
def test_openrouter_key_selected_when_openai_and_groq_absent(self):
|
|
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "sk-or-test"}, clear=True):
|
|
assert _select_key(os.environ) == "sk-or-test"
|
|
|
|
def test_openai_beats_groq_when_both_set(self):
|
|
with patch.dict(os.environ, {"OPENAI_API_KEY": "openai", "GROQ_API_KEY": "groq"}, clear=True):
|
|
assert _select_key(os.environ) == "openai"
|
|
|
|
def test_groq_beats_openrouter_when_openai_absent(self):
|
|
with patch.dict(os.environ, {"GROQ_API_KEY": "groq", "OPENROUTER_API_KEY": "or"}, clear=True):
|
|
assert _select_key(os.environ) == "groq"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Model-prefix → provider URL routing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestProviderUrlMapping:
|
|
|
|
def test_openai_prefix_routes_to_openai(self):
|
|
assert _select_url("openai:gpt-4o") == "https://api.openai.com/v1"
|
|
|
|
def test_groq_prefix_routes_to_groq(self):
|
|
assert _select_url("groq:llama3-70b") == "https://api.groq.com/openai/v1"
|
|
|
|
def test_openrouter_prefix_routes_to_openrouter(self):
|
|
assert _select_url("openrouter:meta-llama/llama-3.3-70b") == "https://openrouter.ai/api/v1"
|
|
|
|
def test_runtime_config_override_wins_over_prefix(self):
|
|
url = _select_url("openai:gpt-4o", {"provider_url": "https://custom.example.com/v1"})
|
|
assert url == "https://custom.example.com/v1"
|
|
|
|
def test_unknown_prefix_falls_back_to_openai(self):
|
|
assert _select_url("some-unknown-model") == "https://api.openai.com/v1"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Negative / fallback cases
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestNegativeAndFallback:
|
|
|
|
def test_no_keys_returns_empty_string(self):
|
|
with patch.dict(os.environ, {}, clear=True):
|
|
assert _select_key(os.environ) == ""
|
|
|
|
def test_unsupported_aistudio_key_returns_empty(self):
|
|
"""Documents that AISTUDIO_API_KEY is NOT yet in the adapter's key chain."""
|
|
with patch.dict(os.environ, {"AISTUDIO_API_KEY": "sk-ai"}, clear=True):
|
|
assert _select_key(os.environ) == ""
|
|
|
|
def test_unsupported_qianfan_key_returns_empty(self):
|
|
"""Documents that QIANFAN_API_KEY is NOT yet in the adapter's key chain."""
|
|
with patch.dict(os.environ, {"QIANFAN_API_KEY": "sk-qf"}, clear=True):
|
|
assert _select_key(os.environ) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. AISTUDIO + QIANFAN — xfail stubs (not yet implemented in adapter.py)
|
|
# These fail now; they should be promoted to passing tests once the adapter
|
|
# adds AISTUDIO_API_KEY and QIANFAN_API_KEY to its key chain and provider_urls.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.xfail(
|
|
strict=True,
|
|
reason=(
|
|
"AISTUDIO_API_KEY not yet in openclaw adapter env-var chain — "
|
|
"add to adapter.py line 84 and provider_urls dict with "
|
|
"URL https://generativelanguage.googleapis.com/v1beta/openai"
|
|
),
|
|
)
|
|
def test_aistudio_key_routes_to_aistudio_url():
|
|
with patch.dict(os.environ, {"AISTUDIO_API_KEY": "sk-ai-test"}, clear=True):
|
|
assert _select_key(os.environ) == "sk-ai-test"
|
|
assert _select_url("gemini-2.5-flash") == "https://generativelanguage.googleapis.com/v1beta/openai"
|
|
|
|
|
|
@pytest.mark.xfail(
|
|
strict=True,
|
|
reason=(
|
|
"QIANFAN_API_KEY not yet in openclaw adapter env-var chain — "
|
|
"add to adapter.py line 84 and provider_urls dict with "
|
|
"URL https://qianfan.baidubce.com/v2"
|
|
),
|
|
)
|
|
def test_qianfan_key_routes_to_qianfan_url():
|
|
with patch.dict(os.environ, {"QIANFAN_API_KEY": "sk-qf-test"}, clear=True):
|
|
assert _select_key(os.environ) == "sk-qf-test"
|
|
assert _select_url("ernie-4.5") == "https://qianfan.baidubce.com/v2"
|