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-fix-copilot-sdk-provider.md

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

72 changes: 72 additions & 0 deletions actions/setup/js/awf_reflect.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ async function enrichReflectModels(reflectData, timeoutMs, logger) {
* reflectUrl: string,
* outputPath: string,
* bytesWritten?: number,
* reflectData?: object,
* reason?: "unexpected_status"|"timeout"|"request_failed",
* status?: number,
* error?: string,
Expand Down Expand Up @@ -275,6 +276,7 @@ async function fetchAWFReflect(options) {
reflectUrl,
outputPath,
bytesWritten: enrichedBody.length,
reflectData,
};
} catch (err) {
const e = /** @type {Error} */ err;
Expand All @@ -300,6 +302,75 @@ async function fetchAWFReflect(options) {
}
}

/**
* Resolve Copilot SDK BYOK custom provider configuration from AWF /reflect data.
* Chooses a configured endpoint and maps it to an OpenAI-compatible provider base URL.
* Returns null when no suitable endpoint is found (e.g. no reflect data, or endpoints not
* configured).
*
* Requires live reflect data passed directly via `reflectData`.
*
* @param {{
* model?: string,
* reflectData: object | null | undefined,
* logger?: (msg: string) => void,
* }} [options]
* @returns {{ model: string, provider: { type: "openai", baseUrl: string } } | null}
*/
function resolveCopilotSDKCustomProviderFromReflect(options) {
const configuredModel = typeof options?.model === "string" ? options.model.trim() : "";
const logger = (options && options.logger) || DEFAULT_REFLECT_LOGGER;

const reflectData = options?.reflectData;
if (reflectData == null) {
logger("sdk-mode: no reflect data provided; cannot resolve custom provider");
return null;
}

const endpoints = Array.isArray(reflectData?.endpoints) ? reflectData.endpoints.filter(ep => ep && ep.configured === true) : [];
if (endpoints.length === 0) {
logger("sdk-mode: no configured endpoints in awf-reflect data; cannot resolve custom provider");
return null;
}

const endpoint =
(configuredModel ? endpoints.find(ep => Array.isArray(ep.models) && ep.models.includes(configuredModel)) : null) ||
endpoints.find(ep => String(ep.provider || "").toLowerCase() === "copilot") ||
endpoints[0];

let baseUrl = "";
if (typeof endpoint?.models_url === "string" && endpoint.models_url) {
try {
baseUrl = new URL(endpoint.models_url).origin;
} catch {
// ignore malformed URL and fall back to port-based construction below
}
}
if (!baseUrl && endpoint?.port != null) {
baseUrl = `http://api-proxy:${String(endpoint.port)}`;
}
if (!baseUrl) {
logger("sdk-mode: unable to derive provider baseUrl from awf-reflect endpoint data; cannot resolve custom provider");
return null;
}

let model = configuredModel;
if (!model && Array.isArray(endpoint?.models)) {
const firstModel = endpoint.models.find(m => typeof m === "string" && m.trim().length > 0);
model = typeof firstModel === "string" ? firstModel.trim() : "";
}
Comment on lines +357 to +361
if (!model) {
logger("sdk-mode: unable to derive model for custom provider from awf-reflect; cannot resolve custom provider");
return null;
}

logger(`sdk-mode: custom provider resolved from awf-reflect (provider=${String(endpoint.provider || "unknown")} baseUrl=${baseUrl} model=${model})`);
return {
model,
provider: { type: "openai", baseUrl },
};
}

if (typeof module !== "undefined" && module.exports) {
module.exports = {
AWF_API_PROXY_REFLECT_URL,
Expand All @@ -314,5 +385,6 @@ if (typeof module !== "undefined" && module.exports) {
extractModelIds,
fetchAWFReflect,
fetchModelsFromUrl,
resolveCopilotSDKCustomProviderFromReflect,
};
}
67 changes: 67 additions & 0 deletions actions/setup/js/awf_reflect.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const {
extractModelIds,
fetchAWFReflect,
fetchModelsFromUrl,
resolveCopilotSDKCustomProviderFromReflect,
} = require("./awf_reflect.cjs");

describe("awf_reflect.cjs", () => {
Expand Down Expand Up @@ -244,6 +245,7 @@ describe("awf_reflect.cjs", () => {
reflectUrl: "http://api-proxy:10000/reflect",
outputPath,
bytesWritten: expect.any(Number),
reflectData: expect.objectContaining({ endpoints: expect.any(Array) }),
});
const saved = JSON.parse(fs.readFileSync(outputPath, "utf8"));
expect(saved.endpoints[0].models).toEqual(["gpt-4o", "gpt-4o-mini"]);
Expand Down Expand Up @@ -305,4 +307,69 @@ describe("awf_reflect.cjs", () => {
expect(collected.length).toBeGreaterThan(0);
});
});

describe("resolveCopilotSDKCustomProviderFromReflect", () => {
it("resolves provider baseUrl and model from port when models_url is absent", () => {
const reflectData = {
endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["gpt-5.4", "claude-sonnet-4.6"] }],
};
expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData })).toEqual({
model: "gpt-5.4",
provider: { type: "openai", baseUrl: "http://api-proxy:10002" },
});
});

it("prefers the endpoint matching the configured model", () => {
const reflectData = {
endpoints: [
{ provider: "openai", port: 10001, configured: true, models: ["gpt-4o"] },
{ provider: "anthropic", port: 10002, configured: true, models: ["claude-sonnet-4.6"] },
],
};
expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData, model: "claude-sonnet-4.6" })).toEqual({
model: "claude-sonnet-4.6",
provider: { type: "openai", baseUrl: "http://api-proxy:10002" },
});
});

it("derives baseUrl from models_url origin when available", () => {
const reflectData = {
endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["gpt-4o"], models_url: "http://172.30.0.30:10002/v1/models" }],
};
expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData })).toEqual({
model: "gpt-4o",
provider: { type: "openai", baseUrl: "http://172.30.0.30:10002" },
});
});

it("returns null when no configured endpoints exist", () => {
const logs = [];
const result = resolveCopilotSDKCustomProviderFromReflect({
reflectData: { endpoints: [{ provider: "copilot", port: 10002, configured: false, models: [] }] },
logger: msg => logs.push(msg),
});
expect(result).toBeNull();
expect(logs.some(l => l.includes("no configured endpoints"))).toBe(true);
});

it("returns null when reflectData is null", () => {
const logs = [];
const result = resolveCopilotSDKCustomProviderFromReflect({
reflectData: null,
logger: msg => logs.push(msg),
});
expect(result).toBeNull();
expect(logs.some(l => l.includes("no reflect data provided"))).toBe(true);
});

it("returns null when reflectData is undefined", () => {
const logs = [];
const result = resolveCopilotSDKCustomProviderFromReflect({
reflectData: undefined,
logger: msg => logs.push(msg),
});
expect(result).toBeNull();
expect(logs.some(l => l.includes("no reflect data provided"))).toBe(true);
});
});
});
122 changes: 44 additions & 78 deletions actions/setup/js/copilot_harness.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const {
extractModelIds,
fetchAWFReflect,
fetchModelsFromUrl,
resolveCopilotSDKCustomProviderFromReflect,
} = require("./awf_reflect.cjs");
const { runSafeOutputsCLI, buildMissingToolAlternatives, emitMissingToolPermissionIssue, emitInfrastructureIncomplete } = require("./safeoutputs_cli.cjs");
const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, buildMissingToolPermissionIssuePayload } = require("./permission_denied_helpers.cjs");
Expand Down Expand Up @@ -230,73 +231,6 @@ function isModelAvailableInReflectFile(model, options) {
}
}

/**
* Resolve Copilot SDK BYOK custom provider configuration from saved AWF /reflect data.
* Chooses a configured endpoint and maps it to an OpenAI-compatible provider base URL.
*
* @param {{
* model?: string,
* reflectPath?: string,
* readFileSync?: (path: string, encoding: string) => string,
* logger?: (msg: string) => void,
* }} [options]
* @returns {{ model: string, provider: { type: "openai", baseUrl: string } } | null}
*/
function resolveCopilotSDKCustomProviderFromReflect(options) {
const configuredModel = typeof options?.model === "string" ? options.model.trim() : "";
const reflectPath = (options && options.reflectPath) || AWF_REFLECT_OUTPUT_PATH;
const readFile = (options && options.readFileSync) || fs.readFileSync;
const logger = (options && options.logger) || log;

try {
const raw = readFile(reflectPath, "utf8");
const reflectData = JSON.parse(raw);
const endpoints = Array.isArray(reflectData?.endpoints) ? reflectData.endpoints.filter(ep => ep && ep.configured === true) : [];
if (endpoints.length === 0) {
logger(`sdk-mode: no configured endpoints in ${reflectPath}; skipping custom provider config`);
return null;
}

const endpoint = (configuredModel ? endpoints.find(ep => Array.isArray(ep.models) && ep.models.includes(configuredModel)) : null) || endpoints.find(ep => String(ep.provider || "").toLowerCase() === "copilot") || endpoints[0];

let baseUrl = "";
if (typeof endpoint?.models_url === "string" && endpoint.models_url) {
try {
baseUrl = new URL(endpoint.models_url).origin;
} catch {
// ignore malformed URL and fall back to port-based construction below
}
}
if (!baseUrl && endpoint?.port != null) {
baseUrl = `http://api-proxy:${String(endpoint.port)}`;
}
if (!baseUrl) {
logger("sdk-mode: unable to derive provider baseUrl from awf-reflect endpoint data; skipping custom provider config");
return null;
}

let model = configuredModel;
if (!model && Array.isArray(endpoint?.models)) {
const firstModel = endpoint.models.find(m => typeof m === "string" && m.trim().length > 0);
model = typeof firstModel === "string" ? firstModel.trim() : "";
}
if (!model) {
logger("sdk-mode: unable to derive model for custom provider from awf-reflect; skipping custom provider config");
return null;
}

logger(`sdk-mode: custom provider resolved from awf-reflect (provider=${String(endpoint.provider || "unknown")} baseUrl=${baseUrl} model=${model})`);
return {
model,
provider: { type: "openai", baseUrl },
};
} catch (error) {
const err = /** @type {Error} */ error;
logger(`sdk-mode: unable to read custom provider config from ${reflectPath}: ${err.message}`);
return null;
}
}

/**
* Determines if the collected output contains a "No authentication information found" error.
* This means no auth token (COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN) is available
Expand Down Expand Up @@ -604,14 +538,6 @@ async function main() {
log("copilot-sdk mode active: generated per-run COPILOT_CONNECTION_TOKEN");
log(`copilot-sdk mode active: COPILOT_SDK_URI=${sdkEnv.COPILOT_SDK_URI || "(not set)"}`);
}
// Merge SDK env additions into the child process env only when the SDK helper
// returned at least one variable; otherwise leave the env undefined so that
// runProcess inherits the full process.env (the common case).
// sdkEnv already contains SDK-mode variables (e.g. COPILOT_SDK_URI) when enabled.
// Always attach the generated per-run COPILOT_CONNECTION_TOKEN so both the sidecar
// (started by the harness) and the SDK client share the same token.
const sdkChildEnv = copilotSDKMode ? { ...sdkEnv, COPILOT_CONNECTION_TOKEN: copilotConnectionToken } : sdkEnv;
const childEnv = Object.keys(sdkChildEnv).length > 0 ? { ...process.env, ...sdkChildEnv } : undefined;

// In driver mode the args are the driver command + copilot binary path; no stdin payload.
// In CLI mode, args are resolved to inline prompt text.
Expand All @@ -622,13 +548,53 @@ async function main() {
resolvedArgs = resolvePromptFileArgs(args);
}

// 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.
// Fetch AWF API proxy reflection data before running the agent.
// In SDK/BYOK mode the live data is used immediately to resolve the custom provider
// configuration that is injected into the driver subprocess environment.
// Skip when AWF_REFLECT_ENABLED is not "1" (e.g. sandbox.agent: false — no api-proxy running).
let awfReflectData = null;
if (process.env.AWF_REFLECT_ENABLED === "1") {
await fetchAWFReflect({ logger: log });
const reflectResult = await fetchAWFReflect({ logger: log });
if (reflectResult.ok && reflectResult.reflectData) {
awfReflectData = reflectResult.reflectData;
}
}
Comment on lines +555 to 561

// Resolve BYOK custom provider from live reflect data (SDK mode only).
// BYOK is the only supported mode for SDK sessions — fail immediately if the provider
// cannot be resolved so retries are not wasted on a misconfigured environment.
let providerBaseUrl = "";
let resolvedModel = "";
if (copilotSDKMode) {
const configuredModel = process.env.COPILOT_MODEL || "";
const customProvider = resolveCopilotSDKCustomProviderFromReflect({ model: configuredModel, reflectData: awfReflectData, logger: log });
if (!customProvider) {
log("copilot-sdk driver mode: BYOK provider is required but could not be resolved from awf-reflect data — aborting");
process.exit(1);
}
providerBaseUrl = customProvider.provider.baseUrl;
resolvedModel = customProvider.model;
log(`copilot-sdk driver mode: BYOK provider resolved (baseUrl=${providerBaseUrl} model=${resolvedModel})`);
}

// Merge SDK env additions into the child process env only when the SDK helper
// returned at least one variable; otherwise leave the env undefined so that
// runProcess inherits the full process.env (the common case).
// sdkEnv already contains SDK-mode variables (e.g. COPILOT_SDK_URI) when enabled.
// Always attach the generated per-run COPILOT_CONNECTION_TOKEN so both the sidecar
// (started by the harness) and the SDK client share the same token.
// In SDK mode also inject the resolved BYOK provider base URL and model so the driver
// subprocess does not need to re-read the reflect file.
const sdkChildEnv = copilotSDKMode
? {
...sdkEnv,
COPILOT_CONNECTION_TOKEN: copilotConnectionToken,
GH_AW_COPILOT_SDK_PROVIDER_BASE_URL: providerBaseUrl,
COPILOT_MODEL: resolvedModel,
}
: sdkEnv;
const childEnv = Object.keys(sdkChildEnv).length > 0 ? { ...process.env, ...sdkChildEnv } : undefined;

let delay = INITIAL_DELAY_MS;
let lastExitCode = 1;
const isScheduledRun = process.env.GITHUB_EVENT_NAME === "schedule";
Expand Down
15 changes: 15 additions & 0 deletions actions/setup/js/copilot_sdk_driver.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,20 @@ async function main() {

log(`connecting to sidecar at ${sdkUri}`);

// --- Resolve BYOK custom provider from environment ------------------
// The harness resolves the BYOK provider from live AWF reflect data before launching
// this driver and injects the result as GH_AW_COPILOT_SDK_PROVIDER_BASE_URL.
// BYOK is the only supported mode — fail immediately if the env var is missing.
const providerBaseUrl = process.env.GH_AW_COPILOT_SDK_PROVIDER_BASE_URL;
if (!providerBaseUrl) {
process.stderr.write(
"[copilot-sdk-driver] error: GH_AW_COPILOT_SDK_PROVIDER_BASE_URL is not set — " +
"BYOK provider is required; ensure the harness resolved a custom provider from awf-reflect data\n"
);
process.exit(1);
}
const provider = /** @type {import("@github/copilot-sdk").ProviderConfig} */ ({ type: "openai", baseUrl: providerBaseUrl });
Comment on lines +499 to +507

// --- Run SDK session -------------------------------------------------

const result = await runWithCopilotSDK({
Expand All @@ -500,6 +514,7 @@ async function main() {
logger: log,
model,
connectionToken,
provider,
});

process.exit(result.exitCode);
Expand Down