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
2 changes: 1 addition & 1 deletion actions/setup/js/claude_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ async function buildClaudeChildEnv(reflectData, env = process.env, logger = log,
if (resolution.reflectData) reflectData = resolution.reflectData;
childEnv.ANTHROPIC_MODEL = resolution.engineModel;
}
const resolved = reflectData ? resolveProviderEndpointFromReflect({ provider: copilotProvider ? "github" : provider, reflectData, logger }) : null;
const resolved = provider === "vertex" ? null : reflectData ? resolveProviderEndpointFromReflect({ provider: copilotProvider ? "github" : provider, reflectData, logger }) : null;
if (copilotProvider && (!resolved || !REFLECT_PROVIDER_ALIASES.github.has(normalizeReflectProviderName(resolved.endpointProvider)))) {
throw new Error("Claude GitHub Copilot inference requires a configured Copilot endpoint from AWF /reflect; keep the agent sandbox enabled and configure permissions.copilot-requests: write or COPILOT_GITHUB_TOKEN");
}
Expand Down
92 changes: 92 additions & 0 deletions actions/setup/js/claude_harness.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,19 @@ describe("claude_harness.cjs", () => {
expect(await buildClaudeChildEnv(undefined, env, () => {})).toMatchObject(env);
});

it("resolves Vertex models without an Anthropic endpoint", async () => {
const env = {
GH_AW_LLM_PROVIDER: "vertex",
ANTHROPIC_MODEL: "claude-sonnet-4-6",
CLAUDE_CODE_USE_VERTEX: "1",
CLAUDE_CODE_SKIP_VERTEX_AUTH: "1",
ANTHROPIC_VERTEX_BASE_URL: "http://api-proxy:10004/v1",
ANTHROPIC_API_KEY: "",
};
const reflect = { endpoints: [{ provider: "vertex", configured: true, port: 10004, models: ["claude-sonnet-4-6"] }] };
expect(await buildClaudeChildEnv(reflect, env, () => {})).toMatchObject(env);
});

it("normalizes a dynamically selected fallback model on the provisioned route", async () => {
const child = await buildClaudeChildEnv(reflectData, { GH_AW_LLM_PROVIDER: "github", GH_AW_MODEL_FALLBACK: "copilot/claude-sonnet-4.6" }, () => {});
expect(child.ANTHROPIC_MODEL).toBe("claude-sonnet-4.6");
Expand Down Expand Up @@ -1199,6 +1212,40 @@ process.exit(0);
expect(result.stderr).toContain("output produced but no Claude session progress — retrying startup as fresh run");
});

it("retries a pending Vertex token exchange before the first assistant response", () => {
const { result, calls } = runHarnessWithStub({
extraEnv: {
GH_AW_LLM_PROVIDER: "vertex",
ANTHROPIC_MODEL: "claude-sonnet-4-6",
CLAUDE_CODE_USE_VERTEX: "1",
CLAUDE_CODE_SKIP_VERTEX_AUTH: "1",
ANTHROPIC_VERTEX_BASE_URL: "http://api-proxy:10004/v1",
CLAUDE_CODE_MAX_RETRIES: "8",
},
stubScript: `
const fs = require("fs");
const callsPath = process.env.CLAUDE_HARNESS_STUB_CALLS;
const pending = !fs.existsSync(callsPath);
fs.appendFileSync(callsPath, JSON.stringify({
args: process.argv.slice(2), stdin: fs.readFileSync(0, "utf8"),
vertex: process.env.CLAUDE_CODE_USE_VERTEX, retries: process.env.CLAUDE_CODE_MAX_RETRIES,
baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL
}) + "\\n");
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "vertex-session" }));
if (pending) {
console.log(JSON.stringify({ type: "result", subtype: "error_during_execution", is_error: true, result: "503 service_unavailable: token exchange pending" }));
process.exit(1);
}
console.log(JSON.stringify({ type: "assistant", session_id: "vertex-session", message: { content: [{ type: "text", text: "Vertex ready" }] } }));
`,
});
expect(result.status, result.stderr).toBe(0);
expect(calls).toHaveLength(2);
expect(calls.map(call => call.args.includes("--resume"))).toEqual([false, false]);
expect(calls[1]).toMatchObject({ vertex: "1", retries: "8", baseUrl: "http://api-proxy:10004/v1" });
expect(result.stdout).toContain("Vertex ready");
});

it("does not fall through to --continue after no-progress output exhausts startup retries", () => {
const stubScript = `
const fs = require("fs");
Expand Down Expand Up @@ -1741,6 +1788,51 @@ describe("Claude sub-agent models before spawn (github/gh-aw#70667)", () => {
expect(records).toContainEqual(resolved);
});

it("resolves Claude aliases against a Vertex model catalog", async () => {
const reflectData = {
endpoints: [{ provider: "vertex", configured: true, port: 10004, models: ["claude-sonnet-5", "claude-haiku-5-5"] }],
};
const child = await buildClaudeChildEnv(reflectData, { GH_AW_LLM_PROVIDER: "vertex", ANTHROPIC_MODEL: "sonnet" }, () => {});
expect(child.ANTHROPIC_MODEL).toBe("claude-sonnet-5");

const { result, calls, records } = runWithStagedAgent({
agentContent: typistAgent(),
reflectData,
env: {
GH_AW_LLM_PROVIDER: "vertex",
CLAUDE_CODE_USE_VERTEX: "1",
CLAUDE_CODE_SKIP_VERTEX_AUTH: "1",
ANTHROPIC_VERTEX_BASE_URL: "http://api-proxy:10004/v1",
},
});
expect(result.status, result.stderr).toBe(0);
expect(calls).toHaveLength(1);
expect(records).toContainEqual(expect.objectContaining({ role: "duplicate-type-finder", configured_model: "small", provider: "vertex", resolved_model: "claude-haiku-5-5", status: "resolved" }));
});

it("passes through concrete Vertex sub-agent ids when AWF has no Vertex model catalogue", () => {
const reflectData = {
endpoints: [{ provider: "anthropic", configured: true, port: 10001, models: null, models_url: null }],
models_fetch_complete: false,
};
const concreteModel = "claude-haiku-5-5";
const { result, calls, records, workspaceAgent } = runWithStagedAgent({
agentContent: typistAgent().replace("model: small", `model: ${concreteModel}`),
reflectData,
env: {
GH_AW_LLM_PROVIDER: "vertex",
CLAUDE_CODE_USE_VERTEX: "1",
CLAUDE_CODE_SKIP_VERTEX_AUTH: "1",
ANTHROPIC_VERTEX_BASE_URL: "http://api-proxy:10004/v1",
},
});

expect(result.status, result.stderr).toBe(0);
expect(calls).toHaveLength(1);
expect(records).toContainEqual(expect.objectContaining({ role: "duplicate-type-finder", configured_model: concreteModel, provider: "vertex", resolved_model: concreteModel, status: "exact" }));
expect(workspaceAgent).toContain(`model: ${concreteModel}`);
});

it("claude_declared_dead_or_unknown_model_fails_before_spawn", () => {
// Fixture mutation: the recorded catalogue without claude-haiku-5-5, requested explicitly.
const reflectData = runs.typistReflect();
Expand Down
29 changes: 22 additions & 7 deletions actions/setup/js/engine_subagent_models.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,17 @@ function classifyUnlistedModel(reflectData, providers) {
return incomplete ? "catalog_unavailable" : "model_unavailable";
}

/**
* Whether the Vertex provider has no model catalogue endpoint in this AWF response.
* @param {any} reflectData
* @returns {boolean}
*/
function hasNoVertexModelCatalog(reflectData) {
if (!reflectData || !Array.isArray(reflectData.endpoints)) return false;
const endpoints = reflectData.endpoints.filter(endpoint => endpoint?.configured === true && normalizeReflectProviderName(endpoint.provider) === "vertex");
return endpoints.length === 0 || endpoints.every(endpoint => !Array.isArray(endpoint.models) && endpoint.models_url == null);
}

/**
* The per-model endpoint metadata (`routing_models`) published by the configured `/reflect`
* endpoints of the engine's providers, or null when none of them publishes it.
Expand Down Expand Up @@ -420,13 +431,17 @@ async function resolveEngineSubagentModels(options) {
const resolvedNote = model !== configuredModel ? ` (resolved to '${model}')` : "";
if (liveCheck && !isModelAvailableInReflectData(model, reflectData, providers)) {
const initialReason = classifyUnlistedModel(reflectData, providers);
const refreshed = await refreshOnce();
if (!refreshed || !isModelAvailableInReflectData(model, refreshed, providers)) {
// A complete catalogue without the model already proves model_unavailable; only an
// incomplete one is reclassified, and only when a refreshed catalogue exists.
const reason = initialReason === "catalog_unavailable" && refreshed ? classifyUnlistedModel(refreshed, providers) : initialReason;
fail(new ModelAliasResolutionError(configuredModel, reason, { provider: result.provider || options.provider }), `Sub-agent model '${configuredModel}'${resolvedNote} is not listed on the configured awf-reflect endpoints.`);
continue;
if (options.provider === "vertex" && result.status === "exact" && initialReason === "catalog_unavailable" && hasNoVertexModelCatalog(reflectData)) {
logger(`claude sub-agent '${agent.name}': Vertex does not publish a model catalogue through awf-reflect; passing through concrete model id '${model}'`);
} else {
const refreshed = await refreshOnce();
if (!refreshed || !isModelAvailableInReflectData(model, refreshed, providers)) {
// A complete catalogue without the model already proves model_unavailable; only an
// incomplete one is reclassified, and only when a refreshed catalogue exists.
const reason = initialReason === "catalog_unavailable" && refreshed ? classifyUnlistedModel(refreshed, providers) : initialReason;
fail(new ModelAliasResolutionError(configuredModel, reason, { provider: result.provider || options.provider }), `Sub-agent model '${configuredModel}'${resolvedNote} is not listed on the configured awf-reflect endpoints.`);
continue;
}
}
}
if (wireApi) {
Expand Down
20 changes: 19 additions & 1 deletion actions/setup/js/resolve_model_alias.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,19 @@ const MODEL_PROVIDER_PREFIXES = Object.freeze({
copilot: ["copilot", "github-copilot", "github"],
openai: ["openai", "codex"],
anthropic: ["anthropic"],
vertex: ["vertex"],
gemini: ["gemini", "google"],
});

const CLAUDE_VERTEX_ALIAS_PATTERNS = Object.freeze({
sonnet: ["vertex/*sonnet*"],
"sonnet-6x": ["vertex/*sonnet-4-5-*", "vertex/*sonnet-4-6*", "vertex/*sonnet-5*"],
haiku: ["vertex/*haiku*"],
opus: ["vertex/*opus*"],
fable: ["vertex/*fable*"],
any: ["vertex/*"],
});

/**
* A known alias could not resolve because its catalog is unavailable or no
* catalog entry matches. Keep those causes distinct; neither permits forwarding
Expand Down Expand Up @@ -725,7 +735,15 @@ async function resolveEngineModel(options) {
const env = options.env || process.env;
const role = options.role || "main";
const configuredModel = String(options.configuredModel || "").trim();
const aliasMap = options.aliasMap !== undefined ? options.aliasMap : loadModelAliasMap({ reflectData: options.reflectData, env });
let aliasMap = options.aliasMap !== undefined ? options.aliasMap : loadModelAliasMap({ reflectData: options.reflectData, env });
if (options.engine === "claude" && canonicalModelProvider(options.provider || "") === "vertex") {
const vertexAliasMap = { ...(aliasMap && typeof aliasMap === "object" && !Array.isArray(aliasMap) ? aliasMap : {}) };
for (const [alias, patterns] of Object.entries(CLAUDE_VERTEX_ALIAS_PATTERNS)) {
const existingPatterns = Array.isArray(vertexAliasMap[alias]) ? vertexAliasMap[alias] : [];
vertexAliasMap[alias] = [...existingPatterns, ...patterns.filter(pattern => !existingPatterns.includes(pattern))];
}
aliasMap = vertexAliasMap;
}
// Without AWF no /reflect catalogue can exist; an explicit catalogue or /reflect data overrides.
const noCatalog = options.noCatalog ?? (env.AWF_REFLECT_ENABLED !== "1" && !options.reflectData && !Array.isArray(options.catalog));
const record = (/** @type {any} */ fields) => {
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/engines/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ Run Claude Code on GitHub events using an Anthropic API key or GitHub Copilot in

Set `engine: claude` and add an [`ANTHROPIC_API_KEY`](/gh-aw/reference/auth/#anthropic_api_key) repository secret. For keyless authentication, use [Anthropic WIF](/gh-aw/reference/auth/#anthropic-workload-identity-federation-wif).

To use Google Cloud billing and GCP WIF instead, configure [Claude on Vertex AI](/gh-aw/reference/auth/#claude-on-vertex-ai). This requires Model Garden access and the AWF Vertex adapter; no Anthropic key is needed.

To bill inference through Copilot instead, add `model: copilot/auto` and `permissions: { copilot-requests: write }`. No Anthropic key is needed.

## Run a daily report
Expand Down
70 changes: 67 additions & 3 deletions docs/src/content/docs/reference/auth.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -414,9 +414,9 @@ When WIF is active, the compiler suppresses the static-key requirement, switches
Vertex AI backend (`GOOGLE_GENAI_USE_VERTEXAI=true`), and emits `AWF_AUTH_GCP_*` environment variables
consumed by the AWF firewall api-proxy sidecar.

This supported WIF path remains specific to `engine: gemini`. Agy ADC/WIF and
Vertex AI support require a separate verified profile; do not migrate a Gemini
WIF workflow to Agy.
Claude also supports GCP WIF through the Vertex adapter, as described below.
Agy ADC/WIF and Vertex AI support require a separate verified profile; do not
migrate a WIF workflow to Agy.

**Prerequisites:**
- A Google Cloud Workload Identity Pool and Provider configured for GitHub Actions
Expand Down Expand Up @@ -468,6 +468,70 @@ Vertex AI backend env vars for the Gemini CLI:

Additionally, `GOOGLE_GENAI_USE_VERTEXAI=true` is always set when GCP WIF is active.

#### Claude on Vertex AI

For `engine: claude`, use the same GCP WIF prerequisites and fields, keep the agent
sandbox enabled, and enable the selected Claude model in the project's
[Vertex AI Model Garden](https://cloud.google.com/gemini-enterprise-agent-platform/models/partner-models/claude/use-claude).
Enable the Vertex AI API and ensure that the project has billing and model quota.
Use a Claude model ID available in your chosen location; do not assume that a
Gemini region or an Anthropic-hosted model ID is available on Vertex.
Check the model card and [Claude Code's Vertex documentation](https://code.claude.com/docs/en/google-vertex-ai)
for current model IDs and regional availability before running.

```yaml wrap
permissions:
contents: read
id-token: write

engine:
id: claude
auth:
type: github-oidc
provider: gcp
workload-identity-provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER
service-account: claude@my-project.iam.gserviceaccount.com
project: my-project
location: global
model: claude-sonnet-4-6
```

Claude defaults `auth.location` to `global`, not Gemini's `us-central1`. Global
availability is model-specific and does not provide regional data residency.
Set an explicitly supported regional location when residency is required.

The compiler applies these values after `engine.env` and sandbox environment merges:

| Environment variable | Value |
|---|---|
| `CLAUDE_CODE_USE_VERTEX` | `1` |
| `CLAUDE_CODE_SKIP_VERTEX_AUTH` | `1` (the sidecar authenticates requests) |
| `ANTHROPIC_VERTEX_BASE_URL` | Internal Vertex adapter URL on port `10004`, ending in `/v1` |
| `ANTHROPIC_VERTEX_PROJECT_ID` | `auth.project` |
| `CLOUD_ML_REGION` | `auth.location`, defaulting to `global` |
| `CLAUDE_CODE_MAX_RETRIES` | `8`, allowing retryable startup 503s during token exchange |

No `ANTHROPIC_API_KEY` secret is required. The execution step clears Anthropic
API-key, bearer-token, subscription-token, and direct-API URL values so unrelated
runner credentials cannot select Anthropic authentication. GCP token exchange stays
in the AWF api-proxy sidecar; do not provide ADC files or access tokens to the agent.
The adapter URL uses `api-proxy` in isolated Docker/microVM topologies and
`host.docker.internal` in host-access or standalone detection/eval topologies.
Both proxy-bypass environment variables include the adapter endpoint.

For projects protected by VPC Service Controls, configure
[perimeter ingress](https://cloud.google.com/vpc-service-controls/docs/ingress-egress-rules)
for the runner's origin and impersonated service account to access Vertex AI
(`aiplatform.googleapis.com`), including Claude prediction methods. Ensure that
the WIF token exchange can reach `sts.googleapis.com` and
`iamcredentials.googleapis.com`, with any required perimeter rules for those
services. WIF configuration alone does not grant perimeter ingress.

> [!NOTE]
> This configures the compiler's Claude Vertex path, not the firewall adapter itself.
> The separate [api-proxy issue #71987](https://github.com/github/gh-aw/issues/71987)
> can still prevent inference. End-to-end inference through gh-aw has not been verified.

---

## Troubleshooting auth errors
Expand Down
11 changes: 11 additions & 0 deletions pkg/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@ const (

// GeminiLLMGatewayPort is the port for the Gemini LLM gateway
GeminiLLMGatewayPort = 10003

// VertexLLMGatewayPort is the port for AWF's Vertex AI adapter.
VertexLLMGatewayPort = 10004
)

// AWFNoProxyHosts is the value for the NO_PROXY and no_proxy environment variables
Expand All @@ -222,6 +225,14 @@ const AWFNoProxyHosts = "localhost,127.0.0.1," +
AWFAPIProxyContainerIP + ":10002," +
AWFAPIProxyContainerIP + ":10003"

// AWFVertexNoProxyHosts adds the Vertex adapter endpoint without changing
// proxy bypass configuration for workflows that do not use Vertex.
const AWFVertexNoProxyHosts = AWFNoProxyHosts + "," +
"host.docker.internal:10004," +
AWFAPIProxyHostname + "," +
AWFAPIProxyHostname + ":10004," +
AWFAPIProxyContainerIP + ":10004"

// DefaultGitHubLockdown is the default value for the GitHub MCP server lockdown setting.
// Lockdown mode restricts the GitHub MCP server to the triggering repository only.
// Defaults to false (lockdown disabled).
Expand Down
Loading
Loading