Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
7 changes: 4 additions & 3 deletions .github/workflows/agentic_commands.yml

Large diffs are not rendered by default.

1,793 changes: 1,793 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