Skip to content
Merged
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
10 changes: 10 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>.wire_api.
wire_api: responses

# Optional Codex custom-provider capabilities, forwarded as
# model_providers.<id>.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
Expand Down
10 changes: 10 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>.wire_api.
wire_api: responses

# Optional Codex custom-provider capabilities, forwarded as
# model_providers.<id>.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
Expand Down
30 changes: 30 additions & 0 deletions docs/generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>`:

| 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:
Expand Down
10 changes: 10 additions & 0 deletions docs/studio.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/teich/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>.wire_api.
wire_api: responses

# Optional Codex custom-provider capabilities, forwarded as
# model_providers.<id>.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
Expand Down
13 changes: 12 additions & 1 deletion src/teich/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>.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):
Expand Down
38 changes: 31 additions & 7 deletions src/teich/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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(
[
Expand All @@ -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)}"
Comment on lines +2874 to +2875

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a Codex-supported web-search setting

When api.supports_standalone_web_search is set, _codex_custom_provider_literal emits a provider key that Codex does not recognize, so Teich's normal non-strict invocation silently ignores it and the advertised web-search opt-in never occurs. I checked Codex CLI 0.144.0-alpha.4 installed in this environment: codex --help describes --strict-config as “Error out when config.toml contains fields that are not recognized,” and running the generated model_providers.gateway table in that mode reports unknown configuration field model_providers.gateway.supports_standalone_web_search (while supports_websockets is accepted); codex features list instead exposes standalone_web_search as a feature. Please map this option to a supported Codex mechanism or remove it until the runtime actually exposes the provider capability.

Useful? React with 👍 / 👎.

)
return "{" + ",".join(fields) + "}"

def _build_codex_command(
self,
prompt: str,
Expand Down
44 changes: 39 additions & 5 deletions src/teich/studio/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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);
Expand All @@ -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",
Expand Down
23 changes: 23 additions & 0 deletions src/teich/studio/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,29 @@ <h1>Setup</h1>
<small id="api-key-hint"></small>
</label>
</div>
<div id="cfg-codex-endpoint" class="codex-endpoint" hidden>
<div class="codex-endpoint-head">
<h3>Codex endpoint</h3>
<p>Applied to the custom model provider when a base URL is set.</p>
</div>
<div class="form-grid">
<label class="field">
<span>Wire API</span>
<select id="cfg-wire-api">
<option value="responses">responses</option>
</select>
<small>Codex custom providers accept only <code>responses</code>.</small>
</label>
<div class="field">
<span>Capabilities</span>
<div class="capability-list">
<label class="checkbox"><input type="checkbox" id="cfg-supports-websockets" /> Supports websockets</label>
<label class="checkbox"><input type="checkbox" id="cfg-supports-web-search" /> Supports standalone web search</label>
</div>
<small>Turn these on when the endpoint supports the Responses WebSocket transport or a compatible standalone web-search API. Off keeps Codex's defaults.</small>
</div>
</div>
</div>
</div>

<details class="card collapsible">
Expand Down
12 changes: 12 additions & 0 deletions src/teich/studio/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading