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
5 changes: 5 additions & 0 deletions .changeset/patch-engine-provider-reflect-routing.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions .github/workflows/agentic_commands.yml

Large diffs are not rendered by default.

1,792 changes: 1,792 additions & 0 deletions .github/workflows/smoke-github-claude.lock.yml

Large diffs are not rendered by default.

45 changes: 45 additions & 0 deletions .github/workflows/smoke-github-claude.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
private: true
emoji: "🧪"
description: Smoke test for Claude engine using GitHub provider that posts a concise PR summary comment
on:
slash_command:
name: smoke-github-claude
strategy: centralized
events: [pull_request, pull_request_comment]
status-comment: true
permissions:
contents: read
pull-requests: read
name: Smoke GitHub Claude
engine:
id: claude
model-provider: github
model: claude-haiku-4.5
Comment on lines +15 to +18
bare: true
strict: true
tools:
github:
mode: gh-proxy
safe-outputs:
allowed-domains: [default-safe-outputs]
add-comment:
max: 1
hide-older-comments: true
timeout-minutes: 10
---

# Smoke Test: Claude on GitHub Provider PR Summary

Goal: validate that Claude with `model-provider: github` can read the current pull request and post one concise summary comment.

1. If this run is not in PR context, call `noop` and stop.
2. Read the current PR details for `${{ github.event.pull_request.number }}` from `${{ github.repository }}`.
3. Produce a short summary with:
- PR title
- author
- file count
- a 2-3 sentence high-level summary of what changed
4. Post exactly one `add_comment` safe output to the current PR with this summary.

Keep the comment compact (max 8 lines).
76 changes: 76 additions & 0 deletions actions/setup/js/awf_reflect.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,35 @@ const AWF_MODELS_URL_OIDC_INITIAL_DELAY_MS_DEFAULT = 5000;
// Gemini model name prefix stripped from model IDs in the Gemini models API response.
// Example: { name: "models/gemini-1.5-pro" } → "gemini-1.5-pro"
const GEMINI_MODEL_NAME_PREFIX = "models/";
const REFLECT_PROVIDER_GITHUB = "github";
const REFLECT_PROVIDER_OPENAI = "openai";
const REFLECT_PROVIDER_ANTHROPIC = "anthropic";
const REFLECT_PROVIDER_ALIASES = {
// Only GitHub has multiple externally-visible aliases in reflect payloads.
github: new Set(["github", "copilot", "github-copilot", "github_models"]),
openai: new Set(["openai"]),
anthropic: new Set(["anthropic"]),
};

// Default logger used by fetchAWFReflect when no logger is provided via options.
// All lines are prefixed with "[awf-reflect]" for easy grepping in combined logs.
// prettier-ignore
const DEFAULT_REFLECT_LOGGER = /** @type {(msg: string) => void} */ (msg => process.stderr.write(`[awf-reflect] ${new Date().toISOString()} ${msg}\n`));

/**
* Normalize provider IDs used in reflect/provider resolution.
*
* @param {unknown} provider
* @param {string} [fallback]
* @returns {string}
*/
function normalizeReflectProviderName(provider, fallback = "") {
const normalized = String(provider || "")
.toLowerCase()
.trim();
return normalized || fallback;
}

/**
* Extract model IDs from a provider API response body.
*
Expand Down Expand Up @@ -502,6 +525,57 @@ function endpointBaseUrl(endpoint) {
return "";
}

/**
* Resolve a configured provider endpoint from AWF /reflect data.
*
* @param {{
* provider?: string,
* reflectData: object | null | undefined,
* logger?: (msg: string) => void,
* }} options
* @returns {{ provider: string, endpointProvider: string, port: number|null, baseUrl: string } | null}
*/
function resolveProviderEndpointFromReflect(options) {
const logger = (options && options.logger) || DEFAULT_REFLECT_LOGGER;
const provider = normalizeReflectProviderName(options?.provider, "openai");
const reflectData = options?.reflectData;
const endpoints = Array.isArray(reflectData?.endpoints) ? reflectData.endpoints.filter(ep => ep && ep.configured === true) : [];
if (endpoints.length === 0) {
logger(`awf-reflect: no configured endpoints available while resolving provider=${provider}`);
return null;
}

/** @param {string} endpointProvider */
const endpointProviderMatches = endpointProvider => {
// Keep aliases aligned with pkg/workflow/llm_provider.go (llmProviderAliases).
// If alias handling changes, update both places in the same PR.
const normalized = normalizeReflectProviderName(endpointProvider);
if (!normalized) return false;
if (provider === REFLECT_PROVIDER_GITHUB) {
return REFLECT_PROVIDER_ALIASES.github.has(normalized);
}
if (provider === REFLECT_PROVIDER_OPENAI) {
return REFLECT_PROVIDER_ALIASES.openai.has(normalized);
}
if (provider === REFLECT_PROVIDER_ANTHROPIC) {
return REFLECT_PROVIDER_ALIASES.anthropic.has(normalized);
}
return normalized === provider;
};

const matched = endpoints.find(ep => endpointProviderMatches(ep?.provider)) || endpoints[0];
const baseUrl = endpointBaseUrl(matched);
if (!baseUrl) {
logger(`awf-reflect: matched provider=${provider} but could not derive baseUrl`);
return null;
}
const endpointProvider = String(matched.provider || "unknown");
const parsedPort = matched.port == null ? null : Number(matched.port);
const port = Number.isFinite(parsedPort) ? parsedPort : null;
logger(`awf-reflect: provider=${provider} mapped to endpoint provider=${endpointProvider} baseUrl=${baseUrl}`);
return { provider, endpointProvider, port, baseUrl };
}

/**
* Resolve multi-provider BYOK configuration from AWF /reflect data.
*
Expand Down Expand Up @@ -645,6 +719,8 @@ if (typeof module !== "undefined" && module.exports) {
getCatalogModelEntry,
inferProviderTypeForModel,
inferWireApiForModel,
normalizeReflectProviderName,
resolveProviderEndpointFromReflect,
resolveMultiProviderFromReflect,
};
}
38 changes: 38 additions & 0 deletions actions/setup/js/awf_reflect.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const {
getCatalogModelEntry,
inferProviderTypeForModel,
inferWireApiForModel,
resolveProviderEndpointFromReflect,
resolveMultiProviderFromReflect,
} = require("./awf_reflect.cjs");

Expand Down Expand Up @@ -88,6 +89,43 @@ describe("awf_reflect.cjs", () => {
vi.unstubAllGlobals();
});

describe("resolveProviderEndpointFromReflect", () => {
it("maps github provider to copilot endpoint", () => {
const resolved = resolveProviderEndpointFromReflect({
provider: "github",
reflectData: {
endpoints: [
{ provider: "openai", configured: true, port: 10000 },
{ provider: "copilot", configured: true, port: 10002, models_url: "http://api-proxy:10002/models" },
],
},
logger: () => {},
});
expect(resolved).toEqual({
provider: "github",
endpointProvider: "copilot",
port: 10002,
baseUrl: "http://api-proxy:10002",
});
});

it("falls back to first configured endpoint when provider is not found", () => {
const resolved = resolveProviderEndpointFromReflect({
provider: "unknown-provider",
reflectData: {
endpoints: [{ provider: "openai", configured: true, port: 10000 }],
},
logger: () => {},
});
expect(resolved).toEqual({
provider: "unknown-provider",
endpointProvider: "openai",
port: 10000,
baseUrl: "http://api-proxy:10000",
});
});
});

it("does nothing when all configured endpoints already have models", async () => {
const reflectData = {
endpoints: [{ provider: "openai", configured: true, models: ["gpt-4o"], models_url: "http://api-proxy:10000/v1/models" }],
Expand Down
27 changes: 26 additions & 1 deletion actions/setup/js/claude_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ const {
extractModelIds,
fetchAWFReflect,
fetchModelsFromUrl,
normalizeReflectProviderName,
resolveProviderEndpointFromReflect,
} = require("./awf_reflect.cjs");
const { emitMissingToolPermissionIssue, hasExpectedSafeOutputs, hasNoopInSafeOutputs } = require("./safeoutputs_cli.cjs");
const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, buildMissingToolPermissionIssuePayload } = require("./permission_denied_helpers.cjs");
Expand Down Expand Up @@ -320,6 +322,28 @@ function stripContinueArgs(args) {
return args.filter(arg => arg !== "--continue");
}

/**
* Build Claude child process env with provider endpoint overrides resolved from /reflect.
* @returns {Promise<NodeJS.ProcessEnv>}
*/
async function buildClaudeChildEnv() {
const childEnv = { ...process.env };
const provider = normalizeReflectProviderName(process.env.GH_AW_LLM_PROVIDER, "anthropic");
try {
const raw = fs.readFileSync(AWF_REFLECT_OUTPUT_PATH, "utf8");
Comment on lines +329 to +333
const reflectData = JSON.parse(raw);
const resolved = resolveProviderEndpointFromReflect({ provider, reflectData, logger: log });
if (resolved && resolved.baseUrl) {
childEnv.ANTHROPIC_BASE_URL = resolved.baseUrl;
log(`configured ANTHROPIC_BASE_URL from /reflect for provider=${provider}: ${resolved.baseUrl}`);
}
} catch (error) {
const err = /** @type {Error} */ error;
log(`warning: unable to resolve provider endpoint from /reflect: ${err.message}`);
}
return childEnv;
}

/**
* Main entry point: run claude with retry logic for transient API failures.
*/
Expand Down Expand Up @@ -364,6 +388,7 @@ async function main() {
// Fetch AWF API proxy reflection data before running the agent to capture initial proxy state.
// This is best-effort: failures are logged but do not affect the agent run.
await fetchAWFReflect({ logger: log });
const childEnv = await buildClaudeChildEnv();

// Pre-flight: skip the agent entirely when a noop has already been written by a prior step.
// A noop indicates the work is complete or there is nothing to do — starting the agent
Expand Down Expand Up @@ -419,7 +444,7 @@ async function main() {
}
}

const result = await runProcess({ command, args: currentArgs, attempt, log, logArgs });
const result = await runProcess({ command, args: currentArgs, attempt, log, logArgs, env: childEnv });
lastExitCode = result.exitCode;

// Success — stop retrying
Expand Down
55 changes: 54 additions & 1 deletion actions/setup/js/codex_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ const {
extractModelIds,
fetchAWFReflect,
fetchModelsFromUrl,
normalizeReflectProviderName,
resolveProviderEndpointFromReflect,
} = require("./awf_reflect.cjs");
const { emitMissingToolPermissionIssue, hasExpectedSafeOutputs, hasNoopInSafeOutputs } = require("./safeoutputs_cli.cjs");
const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, buildMissingToolPermissionIssuePayload } = require("./permission_denied_helpers.cjs");
Expand Down Expand Up @@ -341,6 +343,47 @@ function validateCodexOpenAIBaseURLFromReflect(options) {
return { ok: true };
}

/**
* Configure Codex openai-proxy base_url from /reflect and return env overrides.
*
* @param {{ codexConfigPath?: string, reflectPath?: string, provider?: string }} options
* @returns {{ env: NodeJS.ProcessEnv, configured: boolean }}
*/
function configureCodexProviderFromReflect(options) {
const env = { ...process.env };
const codexConfigPath = options && options.codexConfigPath;
const reflectPath = (options && options.reflectPath) || AWF_REFLECT_OUTPUT_PATH;
const provider = normalizeReflectProviderName(options?.provider || process.env.GH_AW_LLM_PROVIDER, "openai");
if (!reflectPath) return { env, configured: false };
try {
const reflectContent = fs.readFileSync(reflectPath, "utf8");
const reflectData = JSON.parse(reflectContent);
const resolved = resolveProviderEndpointFromReflect({ provider, reflectData, logger: log });
if (!resolved || !resolved.baseUrl) return { env, configured: false };
env.OPENAI_BASE_URL = resolved.baseUrl;
log(`configured OPENAI_BASE_URL from /reflect for provider=${provider}: ${resolved.baseUrl}`);
if (codexConfigPath) {
const tomlContent = fs.readFileSync(codexConfigPath, "utf8");
const providerSectionPattern = /\[model_providers\.openai-proxy\][\s\S]*?(?:\n\[|$)/;
const baseLine = `base_url = "${resolved.baseUrl}"`;
if (providerSectionPattern.test(tomlContent)) {
const rewritten = tomlContent.replace(providerSectionPattern, section => {
if (/(?:^|\n)\s*base_url\s*=/.test(section)) {
return section.replace(/(?:^|\n)\s*base_url\s*=\s*(?:"[^"]*"|'[^']*'|[^\n]+)/, `\n${baseLine}`);
}
return `${section.trimEnd()}\n${baseLine}\n`;
});
fs.writeFileSync(codexConfigPath, rewritten, "utf8");
}
}
return { env, configured: true };
} catch (error) {
const err = /** @type {Error} */ error;
log(`warning: unable to configure provider endpoint from /reflect: ${err.message}`);
return { env, configured: false };
}
}

/**
* Main entry point: run codex with retry logic for transient API failures.
* Codex does not support --continue session resumption, so all retries are fresh runs.
Expand Down Expand Up @@ -406,6 +449,15 @@ async function main() {
// This is best-effort: failures are logged but do not affect the agent run.
await fetchAWFReflect({ logger: log });
const codexHome = process.env.CODEX_HOME || "";
let codexEnv = codexChildEnv;
const providerConfig = configureCodexProviderFromReflect({
codexConfigPath: codexHome ? `${codexHome}/config.toml` : "",
reflectPath: AWF_REFLECT_OUTPUT_PATH,
provider: process.env.GH_AW_LLM_PROVIDER || "openai",
Comment on lines +453 to +456
});
if (providerConfig.configured) {
codexEnv = { ...codexEnv, ...providerConfig.env };
}
Comment on lines +458 to +460
if (codexHome) {
const validation = validateCodexOpenAIBaseURLFromReflect({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/codebase-design] configureCodexProviderFromReflect rewrites the TOML base_url to a Copilot endpoint, but validateCodexOpenAIBaseURLFromReflect immediately re-reads the file and compares the rewritten value against the expected OpenAI gateway port, causing a spurious validation failure when provider=github.

💡 Details

After the rewrite the TOML base_url points to the Copilot endpoint (e.g. port 10002). The existing validation then flags this as unexpected because it still expects the OpenAI port.

Fix: skip validateCodexOpenAIBaseURLFromReflect when providerConfig.configured is true and the resolved provider is not openai, or extend the validation function to accept the resolved provider for correct comparison.

@copilot please address this.

codexConfigPath: `${codexHome}/config.toml`,
Expand Down Expand Up @@ -449,7 +501,7 @@ async function main() {
}
}

const result = await runProcess({ command, args: resolvedArgs, attempt, log, logArgs: safeArgs, env: codexChildEnv });
const result = await runProcess({ command, args: resolvedArgs, attempt, log, logArgs: safeArgs, env: codexEnv });
lastExitCode = result.exitCode;

// Success — stop retrying
Expand Down Expand Up @@ -595,6 +647,7 @@ if (typeof module !== "undefined" && module.exports) {
extractOpenAIProxyBaseURLFromToml,
getConfiguredOpenAIPortFromReflect,
validateCodexOpenAIBaseURLFromReflect,
configureCodexProviderFromReflect,
hasNoopInSafeOutputs,
hasExpectedSafeOutputs,
resolveRetryConfig,
Expand Down
23 changes: 23 additions & 0 deletions actions/setup/js/codex_harness.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const {
extractOpenAIProxyBaseURLFromToml,
getConfiguredOpenAIPortFromReflect,
validateCodexOpenAIBaseURLFromReflect,
configureCodexProviderFromReflect,
hasNoopInSafeOutputs,
resolveRetryConfig,
} = require("./codex_harness.cjs");
Expand Down Expand Up @@ -206,6 +207,28 @@ describe("codex_harness.cjs", () => {
expect(extractPortFromURL("not-a-url")).toBeNull();
});

describe("configureCodexProviderFromReflect", () => {
it("configures OPENAI_BASE_URL from reflected github/copilot endpoint", () => {
const tmpDir = makeHarnessTempDir("codex-provider-");
const configPath = path.join(tmpDir, "config.toml");
const reflectPath = path.join(tmpDir, "awf-reflect.json");
fs.writeFileSync(configPath, `[model_providers.openai-proxy]\nbase_url = "http://172.30.0.30:10000"\n`, "utf8");
fs.writeFileSync(reflectPath, JSON.stringify({ endpoints: [{ provider: "copilot", configured: true, port: 10002 }] }), "utf8");
try {
const result = configureCodexProviderFromReflect({
codexConfigPath: configPath,
reflectPath,
provider: "github",
});
expect(result.configured).toBe(true);
expect(result.env.OPENAI_BASE_URL).toBe("http://api-proxy:10002");
expect(fs.readFileSync(configPath, "utf8")).toContain(`base_url = "http://api-proxy:10002"`);
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});

it("extracts openai-proxy base_url from TOML", () => {
const toml = `
[history]
Expand Down
Loading
Loading