diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 89ab4e5..be5466a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -582,9 +582,19 @@ LLM-invoked tools. Per-tool `input_policy` (trusted parameter injection) is still not implemented — see §11. **Model providers (✅ done, not in the original task list):** -`agent_engine/models/factory.py` builds chat models via `init_chat_model` for -both **Anthropic** and **Amazon Bedrock** (`ChatBedrockConverse`), with clear -configuration errors for missing settings. +`agent_engine/models/factory.py` builds chat models for **Anthropic** (via +`init_chat_model`), **Amazon Bedrock** (`ChatBedrockConverse`), **Google +Gemini** (`ChatGoogleGenerativeAI`), and **OpenAI** (`ChatOpenAI`), with clear +configuration errors for missing settings. The `openai` provider is also the +entry point for any OpenAI-compatible endpoint: `agent_engine/models/presets.py` +holds a small registry mapping known vendor ids (`zai`, `deepseek`, `moonshot`, +`groq`, `xai`, `openrouter`) to their `base_url`/`api_key_env`, so `provider: +zai` in YAML resolves both without either field being typed out. `base_url` +and `api_key_env` remain available directly on `provider: openai` for any +vendor not in the registry, a self-hosted server (Ollama, vLLM), or to +override a listed vendor's preset (routing through an internal proxy, say). +No new dependency either way, it's all `ChatOpenAI`. See +[YAML_SPEC.md](YAML_SPEC.md#any-openai-compatible-endpoint). **CLI (0008 — ✅ done):** `agentctl validate`, `agentctl inspect` (offline summary: agents, MCPs, hooks, diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 99dba3a..91a8e63 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -38,7 +38,8 @@ table above: | ---------- | ------ | ----- | | Runtime hooks (auth/policy/audit/context-enrichment, incl. `transform_tool_result`) | ✅ done | `agent_engine/runtime/hooks/`, [RUNTIME_HOOKS.md](RUNTIME_HOOKS.md) | | Per-run execution-limit guardrails | ✅ done | `agent_engine/core/execution.py`, `agent_engine/runtime/execution.py`, [EXECUTION_LIMITS.md](EXECUTION_LIMITS.md) | -| Amazon Bedrock model provider (in addition to Anthropic) | ✅ done | `agent_engine/models/factory.py` | +| Amazon Bedrock, Google Gemini, and OpenAI model providers (in addition to Anthropic) | ✅ done | `agent_engine/models/factory.py` | +| Any OpenAI-compatible endpoint, with `provider: zai`/`deepseek`/`moonshot`/`groq`/`xai`/`openrouter` shorthand and a `base_url`/`api_key_env` escape hatch for anything else (self-hosted Ollama/vLLM, an unlisted vendor, an internal proxy) | ✅ done | `agent_engine/models/factory.py`, `agent_engine/models/presets.py`, [YAML_SPEC.md](YAML_SPEC.md#any-openai-compatible-endpoint) | | Conversation persistence (SQLite default, sessions, users) | ✅ done | `agent_manager/` | | Embeddable JS/React chat widget | ✅ done | `agent_manager/api/static/widget/` | | Long-term / cross-conversation memory | ⏳ planned | — | diff --git a/docs/YAML_SPEC.md b/docs/YAML_SPEC.md index bdae38d..873bd08 100644 --- a/docs/YAML_SPEC.md +++ b/docs/YAML_SPEC.md @@ -344,6 +344,62 @@ For OpenAI, set `OPENAI_API_KEY` in your environment and install the provider extra with `pip install "agent-engine[openai]"`. Any OpenAI model your key can access may be used via `name`. Secrets must never be stored in YAML. +### Any OpenAI-compatible endpoint + +The `openai` provider is not limited to `api.openai.com`. A handful of +well-known OpenAI-compatible vendors are wired in as `provider` shorthand, so +`base_url` and `api_key_env` resolve automatically: + +```yaml +model: + provider: zai + name: glm-5.2 + temperature: 0.2 +``` + +| `provider` | Vendor | `base_url` | `api_key_env` | +| ---------- | ---------- | ---------------------------------------- | -------------------- | +| `zai` | Z.AI (GLM) | `https://api.z.ai/api/coding/paas/v4` | `ZAI_API_KEY` | +| `deepseek` | DeepSeek | `https://api.deepseek.com/v1` | `DEEPSEEK_API_KEY` | +| `moonshot` | Moonshot | `https://api.moonshot.ai/v1` | `MOONSHOT_API_KEY` | +| `groq` | Groq | `https://api.groq.com/openai/v1` | `GROQ_API_KEY` | +| `xai` | xAI (Grok) | `https://api.x.ai/v1` | `XAI_API_KEY` | +| `openrouter` | OpenRouter | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | + +`name` must be a model ID that vendor's endpoint actually serves (`glm-5.2` +for Z.AI, `deepseek-chat` for DeepSeek, and so on); it is passed through +unmodified, no provider picks a default model on your behalf. + +For anything not in the table (a different vendor, a self-hosted Ollama or +vLLM server, a proxy in front of one of the listed vendors), stay on +`provider: openai` and set `base_url` and `api_key_env` directly: + +```yaml +model: + provider: openai + name: llama3.1 + base_url: http://localhost:11434/v1 + api_key_env: OLLAMA_API_KEY +``` + +`api_key_env` names the environment variable holding the key, it is never +the key itself, and secrets must never be stored in YAML. If a local server +doesn't check the key at all, point `api_key_env` at any variable set to a +placeholder value; the field is still required so the request always sends +an `Authorization` header. + +`base_url`/`api_key_env` also override a listed vendor's preset when both a +`provider` shorthand and one of these fields are set, useful for routing a +known vendor through an internal proxy without giving up the shorthand: + +```yaml +model: + provider: zai + name: glm-5.2 + base_url: https://llm-proxy.internal.example.com/zai + api_key_env: INTERNAL_PROXY_KEY +``` + --- ## Graph Topology diff --git a/src/agent_engine/core/spec.py b/src/agent_engine/core/spec.py index 48c97bf..969b3dd 100644 --- a/src/agent_engine/core/spec.py +++ b/src/agent_engine/core/spec.py @@ -14,6 +14,8 @@ class ModelConfig: region: str | None = None max_tokens: int | None = None top_p: float | None = None + base_url: str | None = None + api_key_env: str | None = None @dataclass(frozen=True) diff --git a/src/agent_engine/engine/langgraph/engine.py b/src/agent_engine/engine/langgraph/engine.py index 118a031..98b6c5f 100644 --- a/src/agent_engine/engine/langgraph/engine.py +++ b/src/agent_engine/engine/langgraph/engine.py @@ -136,6 +136,8 @@ def _model_factory_kwargs(factory: ModelFactory, model: NodeModelConfig) -> dict "region": model.region, "max_tokens": model.max_tokens, "top_p": model.top_p, + "base_url": model.base_url, + "api_key_env": model.api_key_env, } present: dict[str, object] = { key: value for key, value in optional.items() if value is not None diff --git a/src/agent_engine/models/factory.py b/src/agent_engine/models/factory.py index ac898c4..70978aa 100644 --- a/src/agent_engine/models/factory.py +++ b/src/agent_engine/models/factory.py @@ -16,6 +16,7 @@ from langchain_core.language_models import BaseChatModel from agent_engine.logging_config import log +from agent_engine.models.presets import OPENAI_COMPAT_PRESETS logger = logging.getLogger(__name__) @@ -34,6 +35,8 @@ def build_chat_model( region: str | None = None, max_tokens: int | None = None, top_p: float | None = None, + base_url: str | None = None, + api_key_env: str | None = None, ) -> BaseChatModel: """Construct a chat model from flat config fields. @@ -44,11 +47,25 @@ def build_chat_model( if not model_name: raise ModelConfigurationError("Model name must not be empty.") + # A preset id (zai, deepseek, moonshot, groq, xai, openrouter, ...) is + # shorthand for provider: openai with that vendor's base_url/api_key_env + # filled in. YAML-supplied base_url/api_key_env still win if both are + # set explicitly alongside a preset id, so a preset can be overridden + # (a proxy in front of it, a different key variable name) without + # switching away from the shorthand. + preset_id = normalized_provider + preset = OPENAI_COMPAT_PRESETS.get(preset_id) + if preset is not None: + base_url = base_url or preset.base_url + api_key_env = api_key_env or preset.api_key_env + normalized_provider = "openai" + log( logger, logging.INFO, "llm configured", provider=normalized_provider, + provider_preset=preset_id if preset is not None else None, model=model_name, temperature=temperature, region=region, @@ -81,8 +98,10 @@ def build_chat_model( temperature=temperature, max_tokens=max_tokens, top_p=top_p, + base_url=base_url, + api_key_env=api_key_env, ) - supported = ", ".join(_SUPPORTED_PROVIDERS) + supported = ", ".join((*_SUPPORTED_PROVIDERS, *OPENAI_COMPAT_PRESETS)) raise ModelConfigurationError( f"Unsupported model provider '{provider}'. Supported providers: {supported}." ) @@ -205,11 +224,19 @@ def _build_openai_model( temperature: float | None, max_tokens: int | None, top_p: float | None, + base_url: str | None = None, + api_key_env: str | None = None, ) -> BaseChatModel: - api_key = _resolve_openai_api_key() + # `api_key_env` lets this provider point at ANY OpenAI-compatible chat + # completions endpoint (Z.AI, DeepSeek, Moonshot, Groq, xAI, OpenRouter, + # a local Ollama/vLLM server, ...) by naming the env var that holds that + # vendor's key. Defaults to OPENAI_API_KEY to keep existing YAML working + # unchanged. + key_env_var = api_key_env.strip() if api_key_env and api_key_env.strip() else "OPENAI_API_KEY" + api_key = _resolve_openai_api_key(key_env_var) if not api_key: raise ModelConfigurationError( - "OpenAI provider requires OPENAI_API_KEY. " + f"OpenAI-compatible provider requires {key_env_var}. " "Set it in your environment before running agentctl." ) @@ -223,14 +250,17 @@ def _build_openai_model( ) from exc # OpenAI's chat model uses the same `max_tokens` name as extra's config, so - # no remapping is needed. The API key is passed explicitly so OPENAI_API_KEY - # is honored regardless of how the installed SDK resolves credentials. + # no remapping is needed. The API key is passed explicitly so the resolved + # env var is honored regardless of how the installed SDK resolves + # credentials. `base_url` repoints the client at any OpenAI-compatible + # endpoint; when unset, ChatOpenAI's own default (api.openai.com) applies. try: return ChatOpenAI( **_without_none( { "model": name, "api_key": api_key, + "base_url": base_url, "temperature": temperature, "max_tokens": max_tokens, "top_p": top_p, @@ -239,8 +269,8 @@ def _build_openai_model( ) except Exception as exc: raise ModelConfigurationError( - "Could not initialize OpenAI chat model. Verify OPENAI_API_KEY is valid " - "and the model name is an OpenAI model your key can access." + f"Could not initialize OpenAI-compatible chat model. Verify {key_env_var} is " + "valid and the model name is one your key/endpoint can access." ) from exc @@ -257,8 +287,8 @@ def _resolve_gemini_api_key() -> str | None: return key.strip() if key and key.strip() else None -def _resolve_openai_api_key() -> str | None: - key = os.getenv("OPENAI_API_KEY") +def _resolve_openai_api_key(env_var: str = "OPENAI_API_KEY") -> str | None: + key = os.getenv(env_var) return key.strip() if key and key.strip() else None diff --git a/src/agent_engine/models/presets.py b/src/agent_engine/models/presets.py new file mode 100644 index 0000000..28b3236 --- /dev/null +++ b/src/agent_engine/models/presets.py @@ -0,0 +1,70 @@ +"""Known OpenAI-compatible vendor presets. + +Each preset supplies the ``base_url`` and ``api_key_env`` for a vendor whose +API speaks the OpenAI chat completions protocol, so ``provider: `` +in YAML resolves those two fields automatically instead of requiring them to +be typed out by hand every time. A YAML ``model`` block can still override +``base_url`` and/or ``api_key_env`` for a listed vendor (a self-hosted proxy +in front of it, a non-default key variable name, ...), and ``provider: +openai`` with explicit ``base_url``/``api_key_env`` remains the escape hatch +for any vendor or self-hosted server not listed here. + +Presets deliberately do not carry a default model id: no provider in this +factory picks a model on the caller's behalf, and vendor model catalogs +change independently of this codebase, so ``name`` stays required in YAML +exactly as it is for every other provider. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class OpenAICompatPreset: + id: str + name: str + base_url: str + api_key_env: str + + +_PRESET_LIST: tuple[OpenAICompatPreset, ...] = ( + OpenAICompatPreset( + id="zai", + name="Z.AI", + base_url="https://api.z.ai/api/coding/paas/v4", + api_key_env="ZAI_API_KEY", + ), + OpenAICompatPreset( + id="deepseek", + name="DeepSeek", + base_url="https://api.deepseek.com/v1", + api_key_env="DEEPSEEK_API_KEY", + ), + OpenAICompatPreset( + id="moonshot", + name="Moonshot", + base_url="https://api.moonshot.ai/v1", + api_key_env="MOONSHOT_API_KEY", + ), + OpenAICompatPreset( + id="groq", + name="Groq", + base_url="https://api.groq.com/openai/v1", + api_key_env="GROQ_API_KEY", + ), + OpenAICompatPreset( + id="xai", + name="xAI", + base_url="https://api.x.ai/v1", + api_key_env="XAI_API_KEY", + ), + OpenAICompatPreset( + id="openrouter", + name="OpenRouter", + base_url="https://openrouter.ai/api/v1", + api_key_env="OPENROUTER_API_KEY", + ), +) + +OPENAI_COMPAT_PRESETS: dict[str, OpenAICompatPreset] = {p.id: p for p in _PRESET_LIST} diff --git a/src/agent_engine/parsers/yaml/parser.py b/src/agent_engine/parsers/yaml/parser.py index 0f38f19..8394a80 100644 --- a/src/agent_engine/parsers/yaml/parser.py +++ b/src/agent_engine/parsers/yaml/parser.py @@ -32,7 +32,7 @@ from agent_engine.runtime.hooks.models import HOOK_POINTS _SECRET_MARKERS = ("api_key", "apikey", "secret", "token", "password", "private_key") -_SECRET_KEY_EXEMPTIONS = {"max_tokens"} +_SECRET_KEY_EXEMPTIONS = {"max_tokens", "api_key_env"} # For *values*, a marker word alone is not evidence — ordinary prose like # "Handles password reset requests" must pass. Flag a string value only when it @@ -50,6 +50,7 @@ r"|-----BEGIN [A-Z ]*PRIVATE KEY-----" # PEM private keys ) _SUPPORTED_MODEL_PROVIDERS = ("anthropic", "bedrock", "gemini", "openai") +_FIXED_ENDPOINT_PROVIDERS = ("anthropic", "bedrock", "gemini") def _validate_plugins(plugins: Any, errors: list[ValidationError]) -> None: @@ -349,6 +350,17 @@ def _validate_model(path: str, raw: Any, errors: list[ValidationError]) -> None: ) ) + if provider in _FIXED_ENDPOINT_PROVIDERS: + for field_name in ("base_url", "api_key_env"): + if raw.get(field_name) is not None: + errors.append( + ValidationError( + f"{path}.{field_name}", + f"Not supported for provider '{provider}'; only OpenAI-compatible " + "providers accept a custom endpoint or key variable.", + ) + ) + name = raw.get("name") if not isinstance(name, str) or not name.strip(): errors.append(ValidationError(f"{path}.name", "Required non-empty string")) @@ -604,6 +616,8 @@ def _build_model(self, raw: dict[str, Any]) -> ModelConfig: region=raw.get("region"), max_tokens=raw.get("max_tokens"), top_p=raw.get("top_p"), + base_url=raw.get("base_url"), + api_key_env=raw.get("api_key_env"), ) def _build_resolvers( diff --git a/tests/cli/test_validate_command.py b/tests/cli/test_validate_command.py index 38e57a3..eb15d3b 100644 --- a/tests/cli/test_validate_command.py +++ b/tests/cli/test_validate_command.py @@ -184,6 +184,53 @@ def test_yaml_parser_preserves_bedrock_model_fields(tmp_path: Path) -> None: assert model.top_p == 0.8 +def test_validate_rejects_base_url_for_fixed_endpoint_provider(tmp_path: Path) -> None: + spec = _write( + tmp_path, + "system: {name: t}\n" + "defaults: {model: {provider: gemini, name: gemini-2.5-flash, " + "base_url: 'https://proxy.example.com'}}\n" + "agents: {a: {description: d}}\n" + "graph: {a: }\n", + ) + + result = validate_spec(spec) + + assert not result.ok + assert any("base_url" in e and "gemini" in e for e in result.errors) + + +def test_validate_rejects_api_key_env_for_fixed_endpoint_provider(tmp_path: Path) -> None: + spec = _write( + tmp_path, + "system: {name: t}\n" + "defaults: {model: {provider: anthropic, name: claude-haiku-4-5, " + "api_key_env: MY_KEY}}\n" + "agents: {a: {description: d}}\n" + "graph: {a: }\n", + ) + + result = validate_spec(spec) + + assert not result.ok + assert any("api_key_env" in e and "anthropic" in e for e in result.errors) + + +def test_validate_accepts_base_url_and_api_key_env_for_openai(tmp_path: Path) -> None: + spec = _write( + tmp_path, + "system: {name: t}\n" + "defaults: {model: {provider: openai, name: gpt-4.1-mini, " + "base_url: 'https://api.example.com/v1', api_key_env: MY_KEY}}\n" + "agents: {a: {description: d}}\n" + "graph: {a: }\n", + ) + + result = validate_spec(spec) + + assert result.ok, result.errors + + def test_validate_rejects_unsupported_model_provider(tmp_path: Path) -> None: spec = _write( tmp_path, @@ -201,6 +248,7 @@ def test_validate_rejects_unsupported_model_provider(tmp_path: Path) -> None: # -- failing specs ----------------------------------------------------------- + def test_validate_rejects_negative_temperature(tmp_path: Path) -> None: spec = _write( tmp_path, @@ -226,6 +274,7 @@ def test_validate_rejects_non_numeric_temperature(tmp_path: Path) -> None: assert not result.ok assert any("temperature" in e for e in result.errors) + def test_validate_fails_on_invalid_tool_tags(tmp_path: Path) -> None: spec = _write( tmp_path, diff --git a/tests/models/test_factory.py b/tests/models/test_factory.py index 3ced573..41f91c9 100644 --- a/tests/models/test_factory.py +++ b/tests/models/test_factory.py @@ -9,6 +9,7 @@ from agent_engine.models import factory as factory_mod from agent_engine.models.factory import ModelConfigurationError, build_chat_model +from agent_engine.models.presets import OPENAI_COMPAT_PRESETS class _FakeAnthropicModel: @@ -318,6 +319,107 @@ def test_openai_does_not_log_api_key( assert "super-secret-openai-key" not in caplog.text +def test_openai_base_url_points_at_a_compatible_endpoint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _FakeOpenAIModel.instances.clear() + _install_fake_langchain_openai(monkeypatch, _FakeOpenAIModel) + monkeypatch.setenv("ZAI_API_KEY", "zai-test-key") + + result = build_chat_model( + "openai", + "glm-5.2", + temperature=0.2, + base_url="https://api.z.ai/api/coding/paas/v4", + api_key_env="ZAI_API_KEY", + ) + + assert result is _FakeOpenAIModel.instances[0] + assert _FakeOpenAIModel.instances[0].kwargs == { + "model": "glm-5.2", + "api_key": "zai-test-key", + "base_url": "https://api.z.ai/api/coding/paas/v4", + "temperature": 0.2, + } + + +def test_openai_api_key_env_defaults_to_openai_api_key_when_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _FakeOpenAIModel.instances.clear() + _install_fake_langchain_openai(monkeypatch, _FakeOpenAIModel) + monkeypatch.setenv("OPENAI_API_KEY", "oa-test-key") + + build_chat_model("openai", "gpt-4.1-mini", api_key_env=None) + + assert _FakeOpenAIModel.instances[0].kwargs["api_key"] == "oa-test-key" + + +def test_openai_missing_custom_api_key_env_names_that_var_in_the_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _install_fake_langchain_openai(monkeypatch, _FakeOpenAIModel) + monkeypatch.delenv("ZAI_API_KEY", raising=False) + + with pytest.raises(ModelConfigurationError, match="requires ZAI_API_KEY"): + build_chat_model("openai", "glm-5.2", api_key_env="ZAI_API_KEY") + + +@pytest.mark.parametrize("preset_id", sorted(OPENAI_COMPAT_PRESETS)) +def test_preset_provider_resolves_its_own_base_url_and_key_env( + preset_id: str, monkeypatch: pytest.MonkeyPatch +) -> None: + _FakeOpenAIModel.instances.clear() + _install_fake_langchain_openai(monkeypatch, _FakeOpenAIModel) + preset = OPENAI_COMPAT_PRESETS[preset_id] + monkeypatch.setenv(preset.api_key_env, f"{preset_id}-test-key") + + build_chat_model(preset_id, "some-model") + + kwargs = _FakeOpenAIModel.instances[0].kwargs + assert kwargs["base_url"] == preset.base_url + assert kwargs["api_key"] == f"{preset_id}-test-key" + + +def test_preset_provider_yaml_override_wins_over_preset_defaults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _FakeOpenAIModel.instances.clear() + _install_fake_langchain_openai(monkeypatch, _FakeOpenAIModel) + monkeypatch.setenv("MY_ZAI_PROXY_KEY", "proxy-test-key") + + build_chat_model( + "zai", + "glm-5.2", + base_url="https://my-internal-proxy.example.com/v1", + api_key_env="MY_ZAI_PROXY_KEY", + ) + + kwargs = _FakeOpenAIModel.instances[0].kwargs + assert kwargs["base_url"] == "https://my-internal-proxy.example.com/v1" + assert kwargs["api_key"] == "proxy-test-key" + + +def test_preset_provider_is_case_insensitive(monkeypatch: pytest.MonkeyPatch) -> None: + _FakeOpenAIModel.instances.clear() + _install_fake_langchain_openai(monkeypatch, _FakeOpenAIModel) + monkeypatch.setenv("ZAI_API_KEY", "zai-test-key") + + build_chat_model("ZAI", "glm-5.2") + + assert _FakeOpenAIModel.instances[0].kwargs["base_url"] == OPENAI_COMPAT_PRESETS["zai"].base_url + + +def test_unsupported_provider_lists_presets_alongside_base_providers() -> None: + with pytest.raises( + ModelConfigurationError, match="Unsupported model provider 'cohere'" + ) as excinfo: + build_chat_model("cohere", "command-r") + + assert "zai" in str(excinfo.value) + assert "deepseek" in str(excinfo.value) + + def test_unsupported_provider_is_rejected_clearly() -> None: with pytest.raises(ModelConfigurationError, match="Unsupported model provider 'cohere'"): build_chat_model("cohere", "command-r")