diff --git a/config.example.yaml b/config.example.yaml index e861133..4ce9667 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -142,8 +142,18 @@ api: api_key: null # Pi uses OpenRouter through chat/completions even if this is set to responses; # the Responses adapter can stall before the first native session event. + # Codex custom providers accept only "responses" and receive this value as + # model_providers..wire_api. wire_api: responses + # Optional Codex custom-provider capabilities, forwarded as + # model_providers..supports_websockets and supports_standalone_web_search. + # Leave null for Codex's defaults (false). Set true when the endpoint + # supports the Responses WebSocket transport or a compatible standalone + # web-search API. Other agents ignore these fields. + supports_websockets: null + supports_standalone_web_search: null + # Optional MCP servers exposed inside the agent runtime. # mcp_servers: # - name: filesystem diff --git a/config.yaml b/config.yaml index 8847e34..4e81cf6 100644 --- a/config.yaml +++ b/config.yaml @@ -67,8 +67,18 @@ api: api_key: null # Pi uses OpenRouter through chat/completions even if this is set to responses; # the Responses adapter can stall before the first native session event. + # Codex custom providers accept only "responses" and receive this value as + # model_providers..wire_api. wire_api: responses + # Optional Codex custom-provider capabilities, forwarded as + # model_providers..supports_websockets and supports_standalone_web_search. + # Leave null for Codex's defaults (false). Set true when the endpoint + # supports the Responses WebSocket transport or a compatible standalone + # web-search API. Other agents ignore these fields. + supports_websockets: null + supports_standalone_web_search: null + # Optional MCP servers exposed inside the agent runtime. # mcp_servers: # - name: filesystem diff --git a/docs/generation.md b/docs/generation.md index 3cc3622..2416f4f 100644 --- a/docs/generation.md +++ b/docs/generation.md @@ -224,6 +224,36 @@ Copies native Codex session JSONL from mounted `CODEX_HOME/sessions` and normali Teich appends configured `tool_schema` metadata so tools remain available for training even if the model did not call them. +#### Custom endpoints + +When `api.base_url` is set, Teich registers a Codex custom model provider and selects it with `model_provider`. Point that provider at a Responses endpoint and opt it into the capabilities the endpoint actually supports: + +```yaml +agent: + provider: codex + +api: + provider: gateway + base_url: https://example.com/v1 + wire_api: responses + supports_websockets: true + supports_standalone_web_search: true +``` + +Teich writes those fields onto `model_providers.`: + +| Config | Codex provider field | +|--------|----------------------| +| `api.wire_api` | `wire_api` | +| `api.supports_websockets` | `supports_websockets` | +| `api.supports_standalone_web_search` | `supports_standalone_web_search` | + +`wire_api` defaults to `"responses"`. Codex accepts only that value for custom providers. + +`supports_websockets` opts the provider into the Responses WebSocket transport. `supports_standalone_web_search` advertises a compatible standalone web-search endpoint. Leave either field unset (or `null`) to keep Codex's default of `false`. Setting `supports_standalone_web_search: true` does not enable search by itself; Codex still requires web search to be enabled and the selected model and runtime to support it. + +Built-in local providers (`lmstudio`, `ollama`) use Codex's `--oss` path and ignore these fields. Other Teich agents ignore the two capability flags. + #### Using your ChatGPT subscription (host auth) By default Codex runs on an API key. To run it on your ChatGPT subscription instead, point Teich at your host Codex login: diff --git a/docs/studio.md b/docs/studio.md index d2d1084..873e971 100644 --- a/docs/studio.md +++ b/docs/studio.md @@ -70,6 +70,16 @@ That means you can: For `chat`, Studio uses a message-by-message loop over the configured OpenAI-compatible API instead of a terminal. +## Codex endpoints + +When Codex is the selected agent, the Configure tab shows endpoint settings for the custom model provider: + +- **Wire API**, set to `responses` +- **Supports websockets** +- **Supports standalone web search** + +Saving writes `api.wire_api`, `api.supports_websockets`, and `api.supports_standalone_web_search`. Teich forwards them when a base URL is set. Other agents hide these controls and leave the saved values unchanged. + Saved Studio traces go through the same converter path as batch traces, including provider-specific normalization, tool-schema preservation, and incomplete-trace filtering. ## Batch Generation diff --git a/src/teich/cli.py b/src/teich/cli.py index 0acbc7d..23b1b65 100644 --- a/src/teich/cli.py +++ b/src/teich/cli.py @@ -1296,8 +1296,18 @@ def init( api_key: null # Pi uses OpenRouter through chat/completions even if this is set to responses; # the Responses adapter can stall before the first native session event. + # Codex custom providers accept only "responses" and receive this value as + # model_providers..wire_api. wire_api: responses + # Optional Codex custom-provider capabilities, forwarded as + # model_providers..supports_websockets and supports_standalone_web_search. + # Leave null for Codex's defaults (false). Set true when the endpoint + # supports the Responses WebSocket transport or a compatible standalone + # web-search API. Other agents ignore these fields. + supports_websockets: null + supports_standalone_web_search: null + # Optional MCP servers exposed inside the agent runtime. # mcp_servers: # - name: filesystem diff --git a/src/teich/config.py b/src/teich/config.py index 7cceb71..36aa2ae 100644 --- a/src/teich/config.py +++ b/src/teich/config.py @@ -102,11 +102,22 @@ def validate_transport(self) -> MCPConfig: class APIConfig(StrictConfigModel): - """API configuration for OpenAI-compatible endpoints.""" + """API configuration for OpenAI-compatible endpoints. + + ``wire_api`` is the protocol Teich asks the endpoint to speak. Codex custom + providers accept only ``"responses"`` and receive that value as + ``model_providers..wire_api``. + + ``supports_websockets`` and ``supports_standalone_web_search`` are forwarded + only to that Codex custom provider. Leave them unset to keep Codex's + defaults (both false). Other agents ignore them. + """ provider: str = "openai" # openai, openrouter, azure, etc. base_url: str | None = None # e.g., https://openrouter.ai/api/v1 api_key: str | None = None # Override global api_key for this provider wire_api: str = "responses" + supports_websockets: bool | None = None + supports_standalone_web_search: bool | None = None class LangfuseConfig(StrictConfigModel): diff --git a/src/teich/runner.py b/src/teich/runner.py index 5c5b2ed..ebb27bf 100644 --- a/src/teich/runner.py +++ b/src/teich/runner.py @@ -2310,6 +2310,10 @@ def _toml_inline_table(cls, values: dict[str, str]) -> str: ) return "{" + items + "}" + @staticmethod + def _toml_bool(value: bool) -> str: + return "true" if value else "false" + @staticmethod def _provider_key(provider: str) -> str: normalized = re.sub(r"[^a-zA-Z0-9_]+", "_", provider.strip().lower()) @@ -2833,13 +2837,10 @@ def _build_codex_agent_command(self, resume: bool = False) -> list[str]: codex_cmd.append("--dangerously-bypass-hook-trust") if base_url and not self._is_oss_local_provider(provider_name): provider_key = self._custom_provider_key(provider_name) - provider_literal = ( - "{" - f"name={self._toml_string(provider_key)}," - f"base_url={self._toml_string(base_url)}," - f"env_key={self._toml_string(provider_env_key)}," - f"wire_api={self._toml_string(self.config.api.wire_api)}" - "}" + provider_literal = self._codex_custom_provider_literal( + provider_key, + base_url, + provider_env_key, ) codex_cmd.extend( [ @@ -2852,6 +2853,29 @@ def _build_codex_agent_command(self, resume: bool = False) -> list[str]: codex_cmd.append("-") return codex_cmd + def _codex_custom_provider_literal( + self, + provider_key: str, + base_url: str, + provider_env_key: str, + ) -> str: + """Inline TOML table for Codex's custom ``model_providers`` entry.""" + fields = [ + f"name={self._toml_string(provider_key)}", + f"base_url={self._toml_string(base_url)}", + f"env_key={self._toml_string(provider_env_key)}", + f"wire_api={self._toml_string(self.config.api.wire_api)}", + ] + api = self.config.api + if api.supports_websockets is not None: + fields.append(f"supports_websockets={self._toml_bool(api.supports_websockets)}") + if api.supports_standalone_web_search is not None: + fields.append( + "supports_standalone_web_search=" + f"{self._toml_bool(api.supports_standalone_web_search)}" + ) + return "{" + ",".join(fields) + "}" + def _build_codex_command( self, prompt: str, diff --git a/src/teich/studio/static/app.js b/src/teich/studio/static/app.js index 617d4b0..3d6d800 100644 --- a/src/teich/studio/static/app.js +++ b/src/teich/studio/static/app.js @@ -370,6 +370,31 @@ function syncApiProviderOptions() { $("#cfg-base-url").value = API_DEFAULT_URLS.openrouter; delete select.dataset.customValue; } + syncCodexEndpointPanel(); +} + +function isCodexAgent() { + return String(get(state.config, "agent.provider", "pi")).trim().toLowerCase() === "codex"; +} + +function syncCodexEndpointPanel() { + $("#cfg-codex-endpoint").hidden = !isCodexAgent(); +} + +function setWireApiSelect(value) { + const select = $("#cfg-wire-api"); + const normalized = String(value || "responses").trim() || "responses"; + for (const option of Array.from(select.options)) { + if (option.dataset.custom === "true") option.remove(); + } + if (!Array.from(select.options).some((option) => option.value === normalized)) { + const option = document.createElement("option"); + option.value = normalized; + option.textContent = normalized; + option.dataset.custom = "true"; + select.appendChild(option); + } + select.value = normalized; } function fillConfigForm() { @@ -382,6 +407,9 @@ function fillConfigForm() { select.value = ["openrouter", "openai", "anthropic"].includes(apiProvider) ? apiProvider : "custom"; if (select.value === "custom") select.dataset.customValue = apiProvider; $("#cfg-base-url").value = get(c, "api.base_url", "") || ""; + setWireApiSelect(get(c, "api.wire_api", "responses")); + $("#cfg-supports-websockets").checked = Boolean(get(c, "api.supports_websockets", false)); + $("#cfg-supports-web-search").checked = Boolean(get(c, "api.supports_standalone_web_search", false)); syncApiProviderOptions(); $("#cfg-api-key").value = get(c, "api.api_key", "") || ""; $("#cfg-concurrency").value = get(c, "max_concurrency", 1); @@ -406,17 +434,23 @@ function collectConfigUpdates() { const apiProvider = apiProviderSelect.value === "custom" ? (apiProviderSelect.dataset.customValue || "openai") : apiProviderSelect.value; + const api = { + provider: apiProvider, + base_url: $("#cfg-base-url").value.trim() || null, + api_key: $("#cfg-api-key").value.trim() || null, + }; + if (isCodexAgent()) { + api.wire_api = $("#cfg-wire-api").value || "responses"; + api.supports_websockets = $("#cfg-supports-websockets").checked; + api.supports_standalone_web_search = $("#cfg-supports-web-search").checked; + } return { agent: { provider: get(state.config, "agent.provider", "pi") }, model: { model: $("#cfg-model").value.trim(), reasoning_effort: $("#cfg-reasoning").value || null, }, - api: { - provider: apiProvider, - base_url: $("#cfg-base-url").value.trim() || null, - api_key: $("#cfg-api-key").value.trim() || null, - }, + api, output: { traces_dir: $("#cfg-traces-dir").value.trim() || "./output", pretty_name: $("#cfg-pretty-name").value.trim() || "My Agent Traces", diff --git a/src/teich/studio/static/index.html b/src/teich/studio/static/index.html index c3e1ff8..978ec15 100644 --- a/src/teich/studio/static/index.html +++ b/src/teich/studio/static/index.html @@ -107,6 +107,29 @@

Setup

+
diff --git a/src/teich/studio/static/style.css b/src/teich/studio/static/style.css index 34430af..e71b0ce 100644 --- a/src/teich/studio/static/style.css +++ b/src/teich/studio/static/style.css @@ -248,6 +248,18 @@ input::placeholder, textarea::placeholder { color: var(--text-faint); } .checkbox { display: flex; align-items: center; gap: 7px; color: var(--text-dim); font-size: 13px; cursor: pointer; user-select: none; } .checkbox input { width: auto; accent-color: var(--accent); } +.codex-endpoint { + margin-top: 16px; + padding: 14px 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.02); +} +.codex-endpoint-head { margin-bottom: 12px; } +.codex-endpoint-head h3 { margin: 0; font-size: 13px; } +.codex-endpoint-head p { margin: 2px 0 0; color: var(--text-faint); font-size: 12px; } +.capability-list { display: flex; flex-direction: column; gap: 8px; } + /* ----------------------------- Buttons ----------------------------- */ .btn { diff --git a/tests/test_config.py b/tests/test_config.py index 4910858..2728268 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -8,6 +8,40 @@ from teich.config import Config, MCPConfig, ModelConfig +def test_api_config_codex_provider_capabilities_default_unset(): + config = Config() + assert config.api.wire_api == "responses" + assert config.api.supports_websockets is None + assert config.api.supports_standalone_web_search is None + + +def test_api_config_loads_codex_provider_capabilities(tmp_path: Path, monkeypatch): + monkeypatch.delenv("TEICH_MODEL", raising=False) + monkeypatch.delenv("TEICH_BASE_URL", raising=False) + monkeypatch.delenv("TEICH_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("TEICH_PROVIDER", raising=False) + + config_file = tmp_path / "config.yaml" + config_file.write_text(""" +api: + provider: gateway + base_url: https://example.com/v1 + wire_api: responses + supports_websockets: true + supports_standalone_web_search: true +""") + + config = Config.from_yaml(config_file) + + assert config.api.provider == "gateway" + assert config.api.base_url == "https://example.com/v1" + assert config.api.wire_api == "responses" + assert config.api.supports_websockets is True + assert config.api.supports_standalone_web_search is True + + def test_default_config(): """Test default configuration values.""" config = Config() diff --git a/tests/test_runner.py b/tests/test_runner.py index fa70c3c..f966ca2 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -4353,10 +4353,82 @@ def test_custom_api_provider_command(): assert 'model_provider="openrouter"' in cmd_str assert 'env_key="OPENROUTER_API_KEY"' in cmd_str assert 'wire_api="responses"' in cmd_str + assert 'supports_websockets' not in cmd_str + assert 'supports_standalone_web_search' not in cmd_str assert 'OPENROUTER_API_KEY=sk-or-test123' in call_args assert '--oss' not in call_args +def _codex_provider_literal(command: list[str], provider_key: str) -> str: + prefix = f"model_providers.{provider_key}=" + return next(part.removeprefix(prefix) for part in command if part.startswith(prefix)) + + +def test_codex_custom_provider_forwards_endpoint_capabilities(): + config = Config( + model=ModelConfig(model="my-model"), + api=APIConfig( + provider="gateway", + base_url="https://example.com/v1", + api_key="sk-test", + wire_api="responses", + supports_websockets=True, + supports_standalone_web_search=True, + ), + ) + + with patch.object(CodexRunner, "_ensure_image"): + runner = CodexRunner(config) + + command = runner._build_codex_agent_command() + assert _codex_provider_literal(command, "gateway") == ( + '{name="gateway",base_url="https://example.com/v1",' + 'env_key="GATEWAY_API_KEY",wire_api="responses",' + "supports_websockets=true,supports_standalone_web_search=true}" + ) + assert 'model_provider="gateway"' in command + + +def test_codex_custom_provider_can_disable_endpoint_capabilities(): + config = Config( + api=APIConfig( + provider="gateway", + base_url="https://example.com/v1", + wire_api="responses", + supports_websockets=False, + supports_standalone_web_search=False, + ), + ) + + with patch.object(CodexRunner, "_ensure_image"): + runner = CodexRunner(config) + + provider = _codex_provider_literal(runner._build_codex_agent_command(), "gateway") + assert 'wire_api="responses"' in provider + assert "supports_websockets=false" in provider + assert "supports_standalone_web_search=false" in provider + + +def test_codex_local_provider_ignores_endpoint_capabilities(): + config = Config( + api=APIConfig( + provider="ollama", + base_url="http://localhost:11434/v1", + wire_api="responses", + supports_websockets=True, + supports_standalone_web_search=True, + ), + ) + + with patch.object(CodexRunner, "_ensure_image"): + runner = CodexRunner(config) + + command = runner._build_codex_agent_command() + assert not any(part.startswith("model_providers.") for part in command) + assert "supports_websockets" not in command + assert "supports_standalone_web_search" not in command + + def test_openai_custom_base_url_uses_non_reserved_provider_alias(): """Test that custom openai-compatible endpoints do not override reserved built-in provider IDs.""" config = Config( diff --git a/tests/test_studio.py b/tests/test_studio.py index 40ed703..cc10a2f 100644 --- a/tests/test_studio.py +++ b/tests/test_studio.py @@ -318,6 +318,36 @@ def test_status_reports_config_error_for_invalid_config(client): assert payload["prompts_file"].endswith("prompts.jsonl") +def test_config_saves_codex_endpoint_capabilities(client): + response = client.put( + "/api/config", + json={ + "config": { + "agent": {"provider": "codex"}, + "api": { + "provider": "gateway", + "base_url": "https://example.com/v1", + "wire_api": "responses", + "supports_websockets": True, + "supports_standalone_web_search": True, + }, + } + }, + ) + + assert response.status_code == 200 + api = response.json()["config"]["api"] + assert api["provider"] == "gateway" + assert api["base_url"] == "https://example.com/v1" + assert api["wire_api"] == "responses" + assert api["supports_websockets"] is True + assert api["supports_standalone_web_search"] is True + reloaded = client.get("/api/config").json()["config"]["api"] + assert reloaded["wire_api"] == "responses" + assert reloaded["supports_websockets"] is True + assert reloaded["supports_standalone_web_search"] is True + + def test_config_endpoints(client): config = client.get("/api/config").json()["config"] assert config["agent"]["provider"]