Skip to content
Open
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
25 changes: 24 additions & 1 deletion agentflow/agents/pi.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,26 @@

_PI_READ_ONLY_TOOLS = "read,grep,find,ls"
_PI_READ_WRITE_TOOLS = "read,bash,edit,write,grep,find,ls"
_MINIMAX_MODELS = [
{
"id": "MiniMax-M3",
"name": "MiniMax M3",
"reasoning": True,
# Pi's models.json schema currently supports text and image inputs, not video.
"input": ["text", "image"],
# Pi requires numeric rates; MiniMax does not publish a cache-write rate for M3.
"cost": {"input": 0.6, "output": 2.4, "cacheRead": 0.12, "cacheWrite": 0.0},
"contextWindow": 1_000_000,
},
{
"id": "MiniMax-M2.7",
"name": "MiniMax M2.7",
"reasoning": True,
"input": ["text"],
"cost": {"input": 0.3, "output": 1.2, "cacheRead": 0.06, "cacheWrite": 0.375},
"contextWindow": 204_800,
},
]


class PiAdapter(AgentAdapter):
Expand Down Expand Up @@ -99,7 +119,10 @@ def _render_models_json(self, provider: ProviderConfig, model: str | None) -> st
entry["headers"] = dict(provider.headers)
entry["authHeader"] = True
model_id = self._extract_model_id(model, provider.name)
entry["models"] = [{"id": model_id}] if model_id else []
models = [dict(model_entry) for model_entry in _MINIMAX_MODELS] if provider.name == "minimax" else []
if model_id and not any(model_entry["id"] == model_id for model_entry in models):
models.append({"id": model_id})
entry["models"] = models

payload = {"providers": {provider.name: entry}}
return json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
Expand Down
26 changes: 26 additions & 0 deletions agentflow/specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,32 @@ def resolve_provider(value: str | ProviderConfig | None, agent: str | AgentKind)
"provider 'kimi' is not supported for codex nodes because Codex requires an "
"OpenAI Responses API backend and Kimi's public endpoints do not expose /responses"
)
if alias in {"minimax", "minimax-cn"}:
# MiniMax exposes an Anthropic-compatible endpoint (for Claude nodes) and an
# OpenAI-compatible chat-completions endpoint (for Pi nodes, which materialize a
# scoped models.json). Its OpenAI surface is chat completions, not /responses, so
# it cannot back Codex nodes.
china_region = alias == "minimax-cn"
if resolved_agent == AgentKind.CLAUDE:
return ProviderConfig(
name="minimax",
base_url=(
"https://api.minimaxi.com/anthropic"
if china_region
else "https://api.minimax.io/anthropic"
),
api_key_env="MINIMAX_API_KEY",
)
if resolved_agent == AgentKind.PI:
return ProviderConfig(
name="minimax",
base_url="https://api.minimaxi.com/v1" if china_region else "https://api.minimax.io/v1",
api_key_env="MINIMAX_API_KEY",
)
raise ValueError(
f"provider '{alias}' is only supported for `pi` and `claude` nodes; MiniMax's "
"OpenAI-compatible endpoints expose chat completions, not the Responses API Codex requires"
)
return ProviderConfig(name=value)


Expand Down
6 changes: 4 additions & 2 deletions docs/pipelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,11 +238,13 @@ MCP definitions are also validated before launch: `stdio` servers require `comma
Built-in provider shorthands:

- `codex`: `openai`
- `claude`: `anthropic`, `kimi`
- `kimi`: `kimi`, `moonshot`, `moonshot-ai`
- `claude`: `anthropic`, `kimi`, `minimax`, `minimax-cn`
- `pi`: `minimax`, `minimax-cn`

`provider: kimi` is intentionally rejected on `codex` nodes. Codex requires an OpenAI Responses API backend, and Kimi's public endpoints do not expose `/responses`.

`provider: minimax` uses the global Anthropic-compatible endpoint (`https://api.minimax.io/anthropic`) for `claude` nodes and the global OpenAI-compatible chat-completions endpoint (`https://api.minimax.io/v1`) for `pi` nodes. `provider: minimax-cn` uses the corresponding China endpoints (`https://api.minimaxi.com/anthropic` and `https://api.minimaxi.com/v1`). For Pi, both aliases materialize a scoped `models.json` with declarations for `MiniMax-M3` and `MiniMax-M2.7`, including context windows, supported Pi input types, reasoning support, and token rates. Pi's current model schema cannot represent MiniMax-M3 video input or an unavailable cache-write rate, so the scoped declaration advertises text/image input and uses a zero cache-write rate. Both aliases are rejected on `codex` nodes because MiniMax exposes chat completions rather than the Responses API. Use a full `ProviderConfig` for any other custom endpoint.

When both `provider.env` and `node.env` define the same variable, `node.env` wins. For Claude-compatible Kimi setups, `doctor` and `inspect` also recognize providers that set `ANTHROPIC_BASE_URL=https://api.kimi.com/coding/` in `provider.env` even when `provider.base_url` is omitted.

## Execution targets
Expand Down
75 changes: 75 additions & 0 deletions tests/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,81 @@ def test_pi_adapter_passes_provider_name_when_model_bare(tmp_path):
assert prepared.command[provider_idx + 1] == "anthropic"


@pytest.mark.parametrize(
("provider", "base_url"),
[
("minimax", "https://api.minimax.io/v1"),
("minimax-cn", "https://api.minimaxi.com/v1"),
],
)
def test_pi_adapter_resolves_minimax_provider_alias_to_openai_endpoint(tmp_path, provider, base_url):
# The MiniMax aliases resolve to OpenAI-compatible chat-completions
# endpoint with a base_url, so the Pi adapter materializes a scoped
# models.json rather than passing `--provider minimax` to the CLI.
node = NodeSpec.model_validate(
{
"id": "scan",
"agent": "pi",
"prompt": "Scan",
"provider": provider,
"model": "minimax/MiniMax-M3",
}
)
prepared = PiAdapter().prepare(node, "Scan", _paths(tmp_path))

assert "--provider" not in prepared.command
assert "PI_CODING_AGENT_DIR" in prepared.env
models_rel = str(Path("pi-home") / "agent" / "models.json")
assert models_rel in prepared.runtime_files
parsed = json.loads(prepared.runtime_files[models_rel])
entry = parsed["providers"]["minimax"]
assert entry["baseUrl"] == base_url
assert entry["api"] == "openai-completions"
assert entry["apiKey"] == "MINIMAX_API_KEY"
assert entry["models"] == [
{
"id": "MiniMax-M3",
"name": "MiniMax M3",
"reasoning": True,
"input": ["text", "image"],
"cost": {"input": 0.6, "output": 2.4, "cacheRead": 0.12, "cacheWrite": 0.0},
"contextWindow": 1_000_000,
},
{
"id": "MiniMax-M2.7",
"name": "MiniMax M2.7",
"reasoning": True,
"input": ["text"],
"cost": {"input": 0.3, "output": 1.2, "cacheRead": 0.06, "cacheWrite": 0.375},
"contextWindow": 204_800,
},
]


@pytest.mark.parametrize(
("provider", "base_url"),
[
("minimax", "https://api.minimax.io/anthropic"),
("minimax-cn", "https://api.minimaxi.com/anthropic"),
],
)
def test_claude_adapter_supports_minimax_provider_alias(tmp_path, monkeypatch, provider, base_url):
monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-secret")
node = NodeSpec.model_validate(
{
"id": "review",
"agent": "claude",
"prompt": "Review",
"provider": provider,
}
)

prepared = ClaudeAdapter().prepare(node, "Review", _paths(tmp_path))

assert prepared.env["ANTHROPIC_BASE_URL"] == base_url
assert prepared.env["ANTHROPIC_API_KEY"] == "test-minimax-secret"


def test_pi_adapter_materializes_scoped_models_json(tmp_path):
node = NodeSpec.model_validate(
{
Expand Down
14 changes: 14 additions & 0 deletions tests/test_store_and_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,20 @@ def test_pipeline_validation_rejects_codex_kimi_provider_alias():
)


@pytest.mark.parametrize("provider", ["minimax", "minimax-cn"])
def test_pipeline_validation_rejects_codex_minimax_provider_alias(provider):
with pytest.raises(ValueError, match=rf"provider '{provider}' is only supported for `pi` and `claude` nodes"):
PipelineSpec.model_validate(
{
"name": "invalid-provider",
"working_dir": ".",
"nodes": [
{"id": "plan", "agent": "codex", "prompt": "plan", "provider": provider},
],
}
)


def test_pipeline_validation_accepts_graph_inference_setup():
pipeline = PipelineSpec.model_validate(
{
Expand Down