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
164 changes: 145 additions & 19 deletions .github/workflows/shared/pydantic.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
---
runtimes:
uv:
version: latest
python:
version: "3.12"
pre-agent-steps:
- name: Predownload Pydantic AI CLI
run: uv run pai --version
- name: Preinstall Pydantic AI CLI
run: |
python3 -m pip install --quiet --user --disable-pip-version-check "pydantic-ai==$GH_AW_ENGINE_VERSION"
"$HOME/.local/bin/pai" --version
engine:
id: pydantic-ai
version: "0.1.0"
version: "2.26.0"
display-name: Pydantic AI
description: Pydantic AI headless coding agent CLI with MCP support
description: Pydantic AI CLI (pai) running one-shot prompts with MCP tool support
experimental: true
mcp: true
provider:
Expand All @@ -28,21 +30,127 @@ engine:
- raw.githubusercontent.com
- api.github.com
- objects.githubusercontent.com
- pypi.org
- files.pythonhosted.org
provider-domains:
copilot: api.githubcopilot.com
anthropic: api.anthropic.com
openai: api.openai.com
execution:
command-name: uv
args:
- run
- pai
- run
command-name: pai
step-name: Execute Pydantic AI CLI
model-env-var: PAI_MODEL
mcp-config-env-var: GH_AW_MCP_CONFIG
write-timestamp: true
provider-env-mode: universal-llm-consumer
harness-script: |
const { spawnSync } = require("child_process");
const { existsSync, readFileSync } = require("fs");
const { join } = require("path");
const { homedir } = require("os");

const [command, ...commandArgs] = process.argv.slice(2);

const promptFile = process.env.GH_AW_PROMPT;
if (!promptFile) {
throw new Error("GH_AW_PROMPT is not set");
}
const workspace = process.env.GITHUB_WORKSPACE;
if (!workspace) {
throw new Error("GITHUB_WORKSPACE is not set");
}

const localBin = join(homedir(), ".local", "bin");
const env = { ...process.env, PATH: `${localBin}:${process.env.PATH || ""}` };
delete env.COPILOT_GITHUB_TOKEN;
// The AWF api-proxy selects the upstream provider by the port the client connects
// to and injects the real credentials itself, ignoring the inbound key. AWF rewrites
// OPENAI_BASE_URL inside the sandbox to the proxy's OpenAI port, which forwards to
// api.openai.com, so `pai` — which is configured only through the environment — is
// pointed at the port that steers to the configured provider instead, the same
// endpoint Aider and OpenCode use, with the usual placeholder key.
env.OPENAI_API_KEY = "awf-copilot-proxy";
env.OPENAI_BASE_URL = "http://172.30.0.30:10002";

const args = [...commandArgs];
// `pai` sends the model name verbatim, minus the `openai-chat:` provider marker
// that selects its OpenAI-compatible client, so the bare model ID reaches the
// api-proxy — which steers to the configured provider by the port it is reached
// on, not by a prefix in the model name: Copilot rejects `copilot/<model>` with
// `model_not_supported`. The proxy exposes Copilot Claude models under their
// dotted IDs, so `copilot/claude-sonnet-4-5` becomes `claude-sonnet-4.5`.
const model = env.PAI_MODEL?.replace(/^.*\//, "").replace(/^(claude-(?:haiku|sonnet|opus)-\d+)-(\d+)$/, "$1.$2");
if (model) {
args.push("-m", `openai-chat:${model}`);
}
const agentSpec = join(workspace, ".pydantic-ai", "agent.json");
if (existsSync(agentSpec)) {
args.push("-a", agentSpec);
}
args.push(readFileSync(promptFile, "utf8"));

const result = spawnSync(command, args, { cwd: workspace, encoding: "utf8", env });
process.stdout.write(result.stdout || "");
process.stderr.write(result.stderr || "");
if (result.error || result.status !== 0) {
throw new Error(`Pydantic AI execution failed: ${result.error?.message || `exit code ${result.status}`}`);
}
mcp:
config-path: .pydantic-ai/agent.json
config-adapter: |
// Converts the MCP gateway's standard HTTP-based configuration into a
// Pydantic AI agent spec (https://ai.pydantic.dev), which is the only way
// the `pai` CLI can be given MCP servers: each gateway server becomes an
// `MCP` capability entry and the spec file is passed via `pai -a <file>`.
// An agent spec must declare a `model`, but the harness always appends
// `-m openai-chat:<model>` when the workflow declares a model, which takes
// precedence. The value below is only a valid-by-construction fallback for
// workflows that do not declare a model.
const fs = require("fs");
const path = require("path");

const requireEnvVar = name => {
const value = process.env[name];
if (!value) throw new Error(`${name} environment variable is required`);
return value;
};

const gatewayOutputPath = requireEnvVar("MCP_GATEWAY_OUTPUT");
const workspace = requireEnvVar("GITHUB_WORKSPACE");
const gatewayDomain = process.env.MCP_GATEWAY_DOMAIN || "host.docker.internal";
const gatewayPort = requireEnvVar("MCP_GATEWAY_PORT");
const gatewayURL = `http://${gatewayDomain}:${gatewayPort}`;

let cliServers;
try {
cliServers = new Set(JSON.parse(process.env.GH_AW_MCP_CLI_SERVERS || "[]"));
} catch (error) {
throw new Error(`Failed to parse GH_AW_MCP_CLI_SERVERS: ${error instanceof Error ? error.message : String(error)}`);
}

const gatewayOutput = JSON.parse(fs.readFileSync(gatewayOutputPath, "utf8"));
const rawServers = gatewayOutput.mcpServers;
const servers = rawServers && typeof rawServers === "object" && !Array.isArray(rawServers) ? rawServers : {};

const capabilities = [];
for (const [name, entry] of Object.entries(servers)) {
if (cliServers.has(name) || !entry || typeof entry !== "object") continue;
if (typeof entry.url !== "string") {
console.log(`Skipping MCP server ${name}: the Pydantic AI CLI only supports HTTP MCP servers`);
continue;
}
const mcp = {
id: name,
url: entry.url.replace(/^http:\/\/[^/]+\/mcp\//, `${gatewayURL}/mcp/`),
};
if (entry.headers && typeof entry.headers === "object") mcp.headers = entry.headers;
capabilities.push({ MCP: mcp });
}

const configPath = path.join(workspace, ".pydantic-ai", "agent.json");
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify({ model: "openai-chat:gpt-5", capabilities }, null, 2), { mode: 0o600 });
fs.chmodSync(configPath, 0o600);
console.log(`Wrote ${capabilities.length} MCP server(s) to ${configPath}`);
log-parser: |
function parseLog(logContent) {
const lines = logContent.split("\n");
Expand Down Expand Up @@ -118,9 +226,8 @@ engine:
<!--
# Pydantic AI

Shared engine definition for [Pydantic AI](https://ai.pydantic.dev), the
headless AI coding agent. Import this file and set `engine: id: pydantic-ai`
to use it:
Shared engine definition for the [Pydantic AI](https://ai.pydantic.dev) CLI
(`pai`). Import this file and set `engine: id: pydantic-ai` to use it:

```yaml
engine:
Expand All @@ -131,10 +238,29 @@ imports:
```

`model` must use `provider/model` format. Supported providers are `copilot`,
`anthropic`, and `openai`. Requests are routed through the AWF proxy.
`anthropic`, and `openai`. Requests are routed through the AWF api-proxy, which
steers to the configured provider by the port it is reached on, so the provider
segment is dropped and the bare model ID is passed with `-m openai-chat:<model>`
(`openai-chat:` selects the Pydantic AI OpenAI-compatible client and is not part
of the model name sent upstream). Copilot Claude aliases such as
`claude-sonnet-4-5` are normalized to the dotted model IDs exposed by the
proxy, such as `claude-sonnet-4.5`.

Responses are streamed. The proxy's aggregated non-streaming body omits
`object` and `choices[].index`, which Pydantic AI rejects during response
validation, so `--no-stream` is deliberately not passed.

MCP servers are rendered into a Pydantic AI agent spec at
`.pydantic-ai/agent.json` and passed with `-a`, so safe outputs flow through
the standard `safeoutputs` server automatically.

The engine reads MCP server configuration from `GH_AW_MCP_CONFIG`, so
safe outputs flow through the standard `safeoutputs` server automatically.
`pai` is configured only through the environment, so a harness script assembles
the command line and points its OpenAI-compatible client at the AWF api-proxy
port that steers requests to the configured provider. The proxy picks the
upstream provider from the port it is reached on, and AWF rewrites
`OPENAI_BASE_URL` inside the sandbox to the port that forwards to OpenAI, so the
harness overrides it along with the placeholder API key.

Pydantic AI is preinstalled in the runtime image.
The CLI is installed with `pip install --user pydantic-ai==<engine version>`
before the agent runs.
-->
Loading
Loading