From b8ce0de41c35cdef36d11f3601e55d31b1ee1e1f Mon Sep 17 00:00:00 2001 From: 12122J Date: Tue, 14 Jul 2026 17:29:29 +0200 Subject: [PATCH 1/6] feat(models): support any OpenAI-compatible endpoint via base_url/api_key_env The openai provider only ever targeted api.openai.com. Add optional base_url and api_key_env fields to ModelConfig so the same provider covers any OpenAI-compatible vendor (Z.AI, DeepSeek, Moonshot, Groq, xAI, OpenRouter) or self-hosted server (Ollama, vLLM) without a new provider integration or dependency. api_key_env defaults to OPENAI_API_KEY, so existing YAML is unaffected. --- src/agent_engine/core/spec.py | 2 + src/agent_engine/engine/langgraph/engine.py | 2 + src/agent_engine/models/factory.py | 31 ++++++++++---- src/agent_engine/parsers/yaml/parser.py | 2 + tests/models/test_factory.py | 46 +++++++++++++++++++++ 5 files changed, 75 insertions(+), 8 deletions(-) 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..f3650a3 100644 --- a/src/agent_engine/models/factory.py +++ b/src/agent_engine/models/factory.py @@ -34,6 +34,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. @@ -81,6 +83,8 @@ 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) raise ModelConfigurationError( @@ -205,11 +209,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 +235,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 +254,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 +272,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/parsers/yaml/parser.py b/src/agent_engine/parsers/yaml/parser.py index 0f38f19..c93adf0 100644 --- a/src/agent_engine/parsers/yaml/parser.py +++ b/src/agent_engine/parsers/yaml/parser.py @@ -604,6 +604,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/models/test_factory.py b/tests/models/test_factory.py index 3ced573..4d5b635 100644 --- a/tests/models/test_factory.py +++ b/tests/models/test_factory.py @@ -318,6 +318,52 @@ 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") + + def test_unsupported_provider_is_rejected_clearly() -> None: with pytest.raises(ModelConfigurationError, match="Unsupported model provider 'cohere'"): build_chat_model("cohere", "command-r") From 219db2fff929a401cc9ef361ba97e2ebd380521d Mon Sep 17 00:00:00 2001 From: 12122J Date: Tue, 14 Jul 2026 17:29:34 +0200 Subject: [PATCH 2/6] docs: document base_url/api_key_env and fix stale provider claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YAML_SPEC.md now documents the new OpenAI-compatible endpoint fields with a table of known vendors. ARCHITECTURE.md and ROADMAP.md previously only mentioned Anthropic + Bedrock as model providers, even though Gemini and OpenAI have been supported in factory.py for a while — updated both to reflect what's actually implemented. --- docs/ARCHITECTURE.md | 11 ++++++++--- docs/ROADMAP.md | 3 ++- docs/YAML_SPEC.md | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 89ab4e5..624ec14 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -582,9 +582,14 @@ 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 additionally +accepts `base_url` and `api_key_env`, so it also covers any OpenAI-compatible +endpoint — third-party vendors (Z.AI, DeepSeek, Moonshot, Groq, xAI, +OpenRouter, ...) or a self-hosted server (Ollama, vLLM) — without a new +provider integration. 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..2f10448 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 via `provider: openai` + `base_url`/`api_key_env` (Z.AI, DeepSeek, Moonshot, Groq, xAI, OpenRouter, local Ollama/vLLM, ...) | ✅ done | `agent_engine/models/factory.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..4d14266 100644 --- a/docs/YAML_SPEC.md +++ b/docs/YAML_SPEC.md @@ -344,6 +344,42 @@ 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`. Two optional fields +repoint it at any vendor (or self-hosted server) that speaks the OpenAI chat +completions API: + +- `base_url` — the endpoint's base URL. +- `api_key_env` — the environment variable holding that vendor's API key. + Defaults to `OPENAI_API_KEY` when omitted, so existing YAML is unaffected. + +```yaml +model: + provider: openai + name: glm-5.2 + base_url: https://api.z.ai/api/coding/paas/v4 + api_key_env: ZAI_API_KEY + temperature: 0.2 +``` + +Known OpenAI-compatible vendors: + +| Vendor | `base_url` | Typical `api_key_env` | +| ----------- | ------------------------------------------------- | ---------------------- | +| Z.AI (GLM) | `https://api.z.ai/api/coding/paas/v4` | `ZAI_API_KEY` | +| DeepSeek | `https://api.deepseek.com/v1` | `DEEPSEEK_API_KEY` | +| Moonshot | `https://api.moonshot.ai/v1` | `MOONSHOT_API_KEY` | +| Groq | `https://api.groq.com/openai/v1` | `GROQ_API_KEY` | +| xAI (Grok) | `https://api.x.ai/v1` | `XAI_API_KEY` | +| OpenRouter | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | +| Local (Ollama, vLLM, ...) | e.g. `http://localhost:11434/v1` | any placeholder env var — set to a dummy value if the local server doesn't check it | + +The `name` field must be a model ID that vendor's endpoint actually serves +(e.g. `glm-5.2` for Z.AI, `deepseek-chat` for DeepSeek) — it is passed through +unmodified. As with every provider, secrets must never be stored in YAML; +only the *name* of the env var goes in `api_key_env`. + --- ## Graph Topology From bdba0d9f21136885b9c2ce173e449d2766246618 Mon Sep 17 00:00:00 2001 From: 12122J Date: Tue, 14 Jul 2026 17:51:43 +0200 Subject: [PATCH 3/6] feat(models): add provider shorthand for known OpenAI-compatible vendors base_url/api_key_env alone meant typing out both fields by hand every time, even for vendors this codebase already knows about. Add agent_engine/models/presets.py, a small registry mapping provider ids (zai, deepseek, moonshot, groq, xai, openrouter) to their base_url and api_key_env, resolved in build_chat_model before dispatch. provider: zai now works with nothing else set; base_url/api_key_env still override a preset explicitly, or work standalone on provider: openai for any vendor or self-hosted server not in the table. --- src/agent_engine/models/factory.py | 17 +++++++- src/agent_engine/models/presets.py | 70 ++++++++++++++++++++++++++++++ tests/models/test_factory.py | 56 ++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 src/agent_engine/models/presets.py diff --git a/src/agent_engine/models/factory.py b/src/agent_engine/models/factory.py index f3650a3..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__) @@ -46,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, @@ -86,7 +101,7 @@ def build_chat_model( 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}." ) 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/tests/models/test_factory.py b/tests/models/test_factory.py index 4d5b635..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: @@ -364,6 +365,61 @@ def test_openai_missing_custom_api_key_env_names_that_var_in_the_error( 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") From c681310604b563161c57ea1c26952bb818cfcb19 Mon Sep 17 00:00:00 2001 From: 12122J Date: Tue, 14 Jul 2026 17:51:49 +0200 Subject: [PATCH 4/6] docs: document the vendor preset shorthand YAML_SPEC.md leads with provider: zai (etc) as the primary path now, keeps base_url/api_key_env as the fallback for anything not in the table plus the override mechanism. ARCHITECTURE.md and ROADMAP.md updated to match, they still described the plain generic fields from the previous commit. --- docs/ARCHITECTURE.md | 15 ++++++--- docs/ROADMAP.md | 2 +- docs/YAML_SPEC.md | 72 ++++++++++++++++++++++++++++---------------- 3 files changed, 57 insertions(+), 32 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 624ec14..be5466a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -585,11 +585,16 @@ still not implemented — see §11. `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 additionally -accepts `base_url` and `api_key_env`, so it also covers any OpenAI-compatible -endpoint — third-party vendors (Z.AI, DeepSeek, Moonshot, Groq, xAI, -OpenRouter, ...) or a self-hosted server (Ollama, vLLM) — without a new -provider integration. See [YAML_SPEC.md](YAML_SPEC.md#any-openai-compatible-endpoint). +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 2f10448..91a8e63 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -39,7 +39,7 @@ 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, Google Gemini, and OpenAI model providers (in addition to Anthropic) | ✅ done | `agent_engine/models/factory.py` | -| Any OpenAI-compatible endpoint via `provider: openai` + `base_url`/`api_key_env` (Z.AI, DeepSeek, Moonshot, Groq, xAI, OpenRouter, local Ollama/vLLM, ...) | ✅ done | `agent_engine/models/factory.py`, [YAML_SPEC.md](YAML_SPEC.md#any-openai-compatible-endpoint) | +| 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 4d14266..873bd08 100644 --- a/docs/YAML_SPEC.md +++ b/docs/YAML_SPEC.md @@ -346,39 +346,59 @@ 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`. Two optional fields -repoint it at any vendor (or self-hosted server) that speaks the OpenAI chat -completions API: - -- `base_url` — the endpoint's base URL. -- `api_key_env` — the environment variable holding that vendor's API key. - Defaults to `OPENAI_API_KEY` when omitted, so existing YAML is unaffected. +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: openai + provider: zai name: glm-5.2 - base_url: https://api.z.ai/api/coding/paas/v4 - api_key_env: ZAI_API_KEY temperature: 0.2 ``` -Known OpenAI-compatible vendors: - -| Vendor | `base_url` | Typical `api_key_env` | -| ----------- | ------------------------------------------------- | ---------------------- | -| Z.AI (GLM) | `https://api.z.ai/api/coding/paas/v4` | `ZAI_API_KEY` | -| DeepSeek | `https://api.deepseek.com/v1` | `DEEPSEEK_API_KEY` | -| Moonshot | `https://api.moonshot.ai/v1` | `MOONSHOT_API_KEY` | -| Groq | `https://api.groq.com/openai/v1` | `GROQ_API_KEY` | -| xAI (Grok) | `https://api.x.ai/v1` | `XAI_API_KEY` | -| OpenRouter | `https://openrouter.ai/api/v1` | `OPENROUTER_API_KEY` | -| Local (Ollama, vLLM, ...) | e.g. `http://localhost:11434/v1` | any placeholder env var — set to a dummy value if the local server doesn't check it | - -The `name` field must be a model ID that vendor's endpoint actually serves -(e.g. `glm-5.2` for Z.AI, `deepseek-chat` for DeepSeek) — it is passed through -unmodified. As with every provider, secrets must never be stored in YAML; -only the *name* of the env var goes in `api_key_env`. +| `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 +``` --- From 4863fcd7eac51165279678ee4aa480eb489c6989 Mon Sep 17 00:00:00 2001 From: fjgbue Date: Thu, 16 Jul 2026 22:42:27 +0200 Subject: [PATCH 5/6] fix(parsers): reject base_url/api_key_env for fixed-endpoint providers anthropic, bedrock and gemini target a fixed endpoint and their own key, so base_url/api_key_env passed alongside them were silently dropped in the factory. _validate_model now errors at yaml-load time, naming the field and provider, with each field checked independently so setting just one is caught too. --- src/agent_engine/parsers/yaml/parser.py | 12 +++++++++ tests/cli/test_validate_command.py | 34 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/agent_engine/parsers/yaml/parser.py b/src/agent_engine/parsers/yaml/parser.py index c93adf0..c67a7a8 100644 --- a/src/agent_engine/parsers/yaml/parser.py +++ b/src/agent_engine/parsers/yaml/parser.py @@ -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")) diff --git a/tests/cli/test_validate_command.py b/tests/cli/test_validate_command.py index 38e57a3..bb9b8a5 100644 --- a/tests/cli/test_validate_command.py +++ b/tests/cli/test_validate_command.py @@ -184,6 +184,38 @@ 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_rejects_unsupported_model_provider(tmp_path: Path) -> None: spec = _write( tmp_path, @@ -201,6 +233,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 +259,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, From 0a9d92e498be1a6bf32678c762bf479288792e0d Mon Sep 17 00:00:00 2001 From: fjgbue Date: Thu, 16 Jul 2026 22:44:19 +0200 Subject: [PATCH 6/6] fix(parsers): stop the secret scanner blocking api_key_env The yaml secret scanner flags any key whose name looks secret-like, and api_key_env contains "api_key", so it tripped the same check max_tokens was already exempt from. That left the base_url/api_key_env escape hatch unusable from yaml. Exempt the key name; values are still scanned, so a real key pasted there is still rejected. --- src/agent_engine/parsers/yaml/parser.py | 2 +- tests/cli/test_validate_command.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/agent_engine/parsers/yaml/parser.py b/src/agent_engine/parsers/yaml/parser.py index c67a7a8..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 diff --git a/tests/cli/test_validate_command.py b/tests/cli/test_validate_command.py index bb9b8a5..eb15d3b 100644 --- a/tests/cli/test_validate_command.py +++ b/tests/cli/test_validate_command.py @@ -216,6 +216,21 @@ def test_validate_rejects_api_key_env_for_fixed_endpoint_provider(tmp_path: Path 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,