diff --git a/agentflow/agents/pi.py b/agentflow/agents/pi.py index 8f265f3..4e84eae 100644 --- a/agentflow/agents/pi.py +++ b/agentflow/agents/pi.py @@ -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): @@ -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" diff --git a/agentflow/specs.py b/agentflow/specs.py index 857eb92..0e7442c 100644 --- a/agentflow/specs.py +++ b/agentflow/specs.py @@ -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) diff --git a/docs/pipelines.md b/docs/pipelines.md index 95c011e..6086581 100644 --- a/docs/pipelines.md +++ b/docs/pipelines.md @@ -238,11 +238,14 @@ MCP definitions are also validated before launch: `stdio` servers require `comma Built-in provider shorthands: - `codex`: `openai` -- `claude`: `anthropic`, `kimi` +- `claude`: `anthropic`, `kimi`, `minimax`, `minimax-cn` - `kimi`: `kimi`, `moonshot`, `moonshot-ai` +- `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 diff --git a/tests/test_agents.py b/tests/test_agents.py index d2368b2..25e8fd6 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -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( { diff --git a/tests/test_store_and_validation.py b/tests/test_store_and_validation.py index 94fafa2..d04ec66 100644 --- a/tests/test_store_and_validation.py +++ b/tests/test_store_and_validation.py @@ -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( {