diff --git a/actions/setup/js/awf_reflect.cjs b/actions/setup/js/awf_reflect.cjs index 3fd24f6f236..2e0e1d754f4 100644 --- a/actions/setup/js/awf_reflect.cjs +++ b/actions/setup/js/awf_reflect.cjs @@ -409,6 +409,56 @@ function inferProviderTypeForModel(endpointProvider, modelName, modelsJson) { return "openai"; } +/** + * Infer the wire API for a given model ID from the models catalog. + * Returns "responses" when the model's catalog entry specifies `wire_api: "responses"`, + * or "completions" as the safe default for all other cases. + * + * @param {string} modelId + * @param {object | null | undefined} modelsJson + * @returns {"completions" | "responses"} + */ +function inferWireApiForModel(modelId, modelsJson) { + const model = String(modelId || "") + .toLowerCase() + .trim(); + if (!model || modelsJson == null) return "completions"; + + const providers = modelsJson && typeof modelsJson === "object" && !Array.isArray(modelsJson) ? modelsJson.providers : null; + if (providers && typeof providers === "object") { + for (const providerData of Object.values(providers)) { + const models = providerData && typeof providerData === "object" ? providerData.models : null; + if (models && typeof models === "object") { + for (const [catalogModel, catalogEntry] of Object.entries(models)) { + if ( + String(catalogModel || "") + .toLowerCase() + .trim() === model + ) { + if (catalogEntry && catalogEntry.wire_api === "responses") return "responses"; + } + } + } + } + } + + return "completions"; +} + +/** + * Infer the wire API required by a set of model IDs. + * Returns "responses" if any model in the list requires it, "completions" otherwise. + * This is used when a session may involve multiple models (e.g. main model + sub-agent models). + * + * @param {string[]} modelIds + * @param {object | null | undefined} modelsJson + * @returns {"completions" | "responses"} + */ +function inferWireApiForModels(modelIds, modelsJson) { + if (!Array.isArray(modelIds)) return "completions"; + return modelIds.some(id => inferWireApiForModel(id, modelsJson) === "responses") ? "responses" : "completions"; +} + /** * Resolve Copilot SDK BYOK custom provider configuration from AWF /reflect data. * Chooses a configured endpoint and maps it to a provider base URL and type. @@ -424,7 +474,7 @@ function inferProviderTypeForModel(endpointProvider, modelName, modelsJson) { * modelsJson?: object | null, * logger?: (msg: string) => void, * }} [options] - * @returns {{ model: string, provider: { type: "openai" | "azure" | "anthropic", baseUrl: string } } | null} + * @returns {{ model: string, provider: { type: "openai" | "azure" | "anthropic", baseUrl: string, wireApi?: "completions" | "responses" } } | null} */ function resolveCopilotSDKCustomProviderFromReflect(options) { const configuredModel = typeof options?.model === "string" ? options.model.trim() : ""; @@ -476,10 +526,11 @@ function resolveCopilotSDKCustomProviderFromReflect(options) { } const providerType = inferProviderTypeForModel(String(endpoint.provider || ""), model, options?.modelsJson ?? null); + const wireApi = inferWireApiForModel(model, options?.modelsJson ?? null); logger(`sdk-mode: custom provider resolved from awf-reflect (provider=${String(endpoint.provider || "unknown")} type=${providerType} baseUrl=${baseUrl} model=${model})`); return { model, - provider: { type: providerType, baseUrl }, + provider: { type: providerType, baseUrl, ...(wireApi !== "completions" ? { wireApi } : {}) }, }; } @@ -498,6 +549,8 @@ if (typeof module !== "undefined" && module.exports) { fetchAWFReflect, fetchModelsFromUrl, inferProviderTypeForModel, + inferWireApiForModel, + inferWireApiForModels, resolveCopilotSDKCustomProviderFromReflect, }; } diff --git a/actions/setup/js/awf_reflect.test.cjs b/actions/setup/js/awf_reflect.test.cjs index bd8ad68f1cc..501690f1175 100644 --- a/actions/setup/js/awf_reflect.test.cjs +++ b/actions/setup/js/awf_reflect.test.cjs @@ -19,6 +19,8 @@ const { fetchAWFReflect, fetchModelsFromUrl, inferProviderTypeForModel, + inferWireApiForModel, + inferWireApiForModels, resolveCopilotSDKCustomProviderFromReflect, } = require("./awf_reflect.cjs"); @@ -396,6 +398,90 @@ describe("awf_reflect.cjs", () => { }); }); + describe("inferWireApiForModel", () => { + it("returns 'completions' for an unknown model", () => { + expect(inferWireApiForModel("unknown-model", null)).toBe("completions"); + expect(inferWireApiForModel("unknown-model", {})).toBe("completions"); + }); + + it("returns 'completions' for a model without a wire_api field", () => { + const modelsJson = { + providers: { + openai: { models: { "gpt-5.4": { provider_type: "openai", cost: {} } } }, + }, + }; + expect(inferWireApiForModel("gpt-5.4", modelsJson)).toBe("completions"); + }); + + it("returns 'responses' for gpt-5.5 which has wire_api: responses", () => { + const modelsJson = { + providers: { + openai: { models: { "gpt-5.5": { provider_type: "openai", wire_api: "responses", cost: {} } } }, + }, + }; + expect(inferWireApiForModel("gpt-5.5", modelsJson)).toBe("responses"); + }); + + it("finds a model across multiple providers", () => { + const modelsJson = { + providers: { + "github-copilot": { models: { "gpt-5.4": { provider_type: "openai", cost: {} } } }, + openai: { models: { "gpt-5.5": { provider_type: "openai", wire_api: "responses", cost: {} } } }, + }, + }; + expect(inferWireApiForModel("gpt-5.5", modelsJson)).toBe("responses"); + expect(inferWireApiForModel("gpt-5.4", modelsJson)).toBe("completions"); + }); + + it("returns 'completions' when modelsJson is null or undefined", () => { + expect(inferWireApiForModel("gpt-5.5", null)).toBe("completions"); + expect(inferWireApiForModel("gpt-5.5", undefined)).toBe("completions"); + }); + }); + + describe("inferWireApiForModels", () => { + const modelsJson = { + providers: { + openai: { + models: { + "gpt-5.4": { provider_type: "openai", cost: {} }, + "gpt-5.5": { provider_type: "openai", wire_api: "responses", cost: {} }, + "gpt-5.5-pro": { provider_type: "openai", wire_api: "responses", cost: {} }, + }, + }, + }, + }; + + it("returns 'completions' when no model needs responses", () => { + expect(inferWireApiForModels(["gpt-5.4"], modelsJson)).toBe("completions"); + expect(inferWireApiForModels(["gpt-5.4", "claude-sonnet-4.5"], modelsJson)).toBe("completions"); + }); + + it("returns 'responses' when any model needs it", () => { + expect(inferWireApiForModels(["gpt-5.5"], modelsJson)).toBe("responses"); + expect(inferWireApiForModels(["gpt-5.4", "gpt-5.5"], modelsJson)).toBe("responses"); + expect(inferWireApiForModels(["gpt-5.5-pro", "gpt-5.4"], modelsJson)).toBe("responses"); + }); + + it("returns 'responses' when a subagent model requires it and the main model does not", () => { + // Simulates main model = gpt-5.4 (completions), subagent = gpt-5.5 (responses) + expect(inferWireApiForModels(["gpt-5.4", "gpt-5.5"], modelsJson)).toBe("responses"); + }); + + it("returns 'completions' for an empty list", () => { + expect(inferWireApiForModels([], modelsJson)).toBe("completions"); + }); + + it("returns 'completions' when modelsJson is null", () => { + expect(inferWireApiForModels(["gpt-5.5"], null)).toBe("completions"); + }); + + it("returns 'completions' for a non-array input", () => { + expect(inferWireApiForModels(null, modelsJson)).toBe("completions"); + expect(inferWireApiForModels(undefined, modelsJson)).toBe("completions"); + }); + }); + describe("resolveCopilotSDKCustomProviderFromReflect", () => { it("resolves provider baseUrl and model from port when models_url is absent", () => { const reflectData = { @@ -517,5 +603,33 @@ describe("awf_reflect.cjs", () => { expect(result).toBeNull(); expect(logs.some(l => l.includes("no reflect data provided"))).toBe(true); }); + + it("omits wireApi from provider when model uses default completions API", () => { + const reflectData = { + endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["gpt-5.4"] }], + }; + const modelsJson = { + providers: { + openai: { models: { "gpt-5.4": { provider_type: "openai", cost: {} } } }, + }, + }; + const result = resolveCopilotSDKCustomProviderFromReflect({ reflectData, model: "gpt-5.4", modelsJson }); + expect(result).not.toBeNull(); + expect(result.provider).not.toHaveProperty("wireApi"); + }); + + it("includes wireApi: 'responses' in provider when model requires responses API", () => { + const reflectData = { + endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["gpt-5.5"] }], + }; + const modelsJson = { + providers: { + openai: { models: { "gpt-5.5": { provider_type: "openai", wire_api: "responses", cost: {} } } }, + }, + }; + const result = resolveCopilotSDKCustomProviderFromReflect({ reflectData, model: "gpt-5.5", modelsJson }); + expect(result).not.toBeNull(); + expect(result.provider.wireApi).toBe("responses"); + }); }); }); diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 980a9f654c3..de2fbdf40b8 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -57,6 +57,7 @@ const { fetchAWFReflect, fetchModelsFromUrl, inferProviderTypeForModel, + inferWireApiForModels, resolveCopilotSDKCustomProviderFromReflect, } = require("./awf_reflect.cjs"); const { runSafeOutputsCLI, buildMissingToolAlternatives, emitMissingToolPermissionIssue, emitInfrastructureIncomplete, hasExpectedSafeOutputs, hasNoopInSafeOutputs } = require("./safeoutputs_cli.cjs"); @@ -659,6 +660,50 @@ function resolvePromptFileArgs(args) { return resolvedArgs; } +/** + * Extract the `model:` value from a YAML frontmatter block at the start of a string. + * Returns an empty string if the content has no frontmatter or no model field. + * + * @param {string} content + * @returns {string} + */ +function extractModelFromAgentFrontmatter(content) { + const match = String(content || "").match(/^---\r?\n([\s\S]*?)\n---/); + if (!match) return ""; + const modelMatch = match[1].match(/^model:\s*(.+)$/m); + return modelMatch ? modelMatch[1].trim() : ""; +} + +/** + * Scan `.github/agents/*.agent.md` files under `baseDir` and return the model + * identifiers declared in each file's YAML frontmatter. + * Silently ignores missing directory or unreadable files. + * + * @param {string} baseDir - Workspace root (typically GITHUB_WORKSPACE). + * @returns {string[]} + */ +function collectSubAgentModelIds(baseDir) { + if (!baseDir) return []; + const agentsDir = require("path").join(baseDir, ".github", "agents"); + const modelIds = []; + try { + const files = fs.readdirSync(agentsDir); + for (const file of files) { + if (!file.endsWith(".agent.md")) continue; + try { + const content = fs.readFileSync(require("path").join(agentsDir, file), "utf8"); + const model = extractModelFromAgentFrontmatter(content); + if (model) modelIds.push(model); + } catch { + // ignore unreadable files + } + } + } catch { + // directory doesn't exist or is unreadable — not an error + } + return modelIds; +} + /** * Main entry point: run copilot with retry logic for partially-executed sessions. */ @@ -716,6 +761,7 @@ async function main() { // cannot be resolved so retries are not wasted on a misconfigured environment. let providerBaseUrl = ""; let providerType = "openai"; + let providerWireApi = ""; let resolvedModel = ""; if (copilotSDKMode) { const configuredModel = process.env.COPILOT_MODEL || ""; @@ -728,8 +774,25 @@ async function main() { } providerBaseUrl = customProvider.provider.baseUrl; providerType = customProvider.provider.type || "openai"; + providerWireApi = customProvider.provider.wireApi || ""; resolvedModel = customProvider.model; log(`copilot-sdk driver mode: BYOK provider resolved (baseUrl=${providerBaseUrl} type=${providerType} model=${resolvedModel})`); + + // Upgrade wireApi to "responses" if any inline sub-agent model requires it. + // The session-level provider wireApi applies to all model calls, including sub-agents, + // so we must use "responses" whenever any model in the session needs it. + if (providerWireApi !== "responses") { + const workspace = process.env.GITHUB_WORKSPACE || ""; + const subAgentModelIds = collectSubAgentModelIds(workspace); + if (subAgentModelIds.length > 0) { + log(`copilot-sdk driver mode: found ${subAgentModelIds.length} sub-agent model(s): ${subAgentModelIds.join(", ")}`); + const effectiveWireApi = inferWireApiForModels([resolvedModel, ...subAgentModelIds], modelsJson); + if (effectiveWireApi === "responses") { + log(`copilot-sdk driver mode: upgrading wireApi to "${effectiveWireApi}" to support sub-agent model(s)`); + providerWireApi = effectiveWireApi; + } + } + } } // Merge SDK env additions into the child process env only when the SDK helper @@ -746,6 +809,7 @@ async function main() { COPILOT_CONNECTION_TOKEN: copilotConnectionToken, GH_AW_COPILOT_SDK_PROVIDER_BASE_URL: providerBaseUrl, GH_AW_COPILOT_SDK_PROVIDER_TYPE: providerType, + GH_AW_COPILOT_SDK_PROVIDER_WIRE_API: providerWireApi, COPILOT_MODEL: resolvedModel, } : sdkEnv; diff --git a/actions/setup/js/copilot_sdk_driver.cjs b/actions/setup/js/copilot_sdk_driver.cjs index 9d3bd01d458..825b1a47beb 100644 --- a/actions/setup/js/copilot_sdk_driver.cjs +++ b/actions/setup/js/copilot_sdk_driver.cjs @@ -13,6 +13,7 @@ * COPILOT_MODEL — model override (optional) * GH_AW_COPILOT_SDK_PROVIDER_BASE_URL — BYOK provider base URL (set by the harness) * GH_AW_COPILOT_SDK_PROVIDER_TYPE — BYOK provider type: "openai" | "azure" | "anthropic" (set by the harness) + * GH_AW_COPILOT_SDK_PROVIDER_WIRE_API — BYOK provider wire API: "completions" | "responses" (set by the harness) * GH_AW_COPILOT_SDK_SERVER_ARGS — JSON-encoded allow-tool sidecar args (set by the engine) * * The sidecar is started and stopped by the harness; the driver only opens a @@ -90,8 +91,8 @@ async function main() { // --- 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 and - // GH_AW_COPILOT_SDK_PROVIDER_TYPE. + // this driver and injects the result as GH_AW_COPILOT_SDK_PROVIDER_BASE_URL, + // GH_AW_COPILOT_SDK_PROVIDER_TYPE, and GH_AW_COPILOT_SDK_PROVIDER_WIRE_API. // BYOK is the only supported mode — fail immediately if the base URL is missing. const providerBaseUrl = process.env.GH_AW_COPILOT_SDK_PROVIDER_BASE_URL; if (!providerBaseUrl) { @@ -102,8 +103,14 @@ async function main() { /** @type {"openai" | "azure" | "anthropic"} */ const providerType = rawProviderType === "anthropic" || rawProviderType === "azure" ? rawProviderType : "openai"; log(`provider type: ${providerType}`); + const rawWireApi = process.env.GH_AW_COPILOT_SDK_PROVIDER_WIRE_API || ""; + /** @type {"completions" | "responses" | undefined} */ + const wireApi = rawWireApi === "responses" ? "responses" : rawWireApi === "completions" ? "completions" : undefined; /** @type {import("@github/copilot-sdk").ProviderConfig} */ - const provider = { type: providerType, baseUrl: providerBaseUrl }; + const provider = { type: providerType, baseUrl: providerBaseUrl, ...(wireApi && { wireApi }) }; + if (wireApi) { + log(`provider wire API: ${wireApi}`); + } // --- Build permission config from sidecar server args ---------------- // GH_AW_COPILOT_SDK_SERVER_ARGS holds the JSON-encoded --allow-tool flags diff --git a/actions/setup/js/models.json b/actions/setup/js/models.json index 394b698e00d..fbdc8ade0bc 100644 --- a/actions/setup/js/models.json +++ b/actions/setup/js/models.json @@ -287,7 +287,8 @@ "output": "3e-05", "cache_read": "5e-07" }, - "provider_type": "openai" + "provider_type": "openai", + "wire_api": "responses" }, "claude-opus-4.5": { "cost": { @@ -580,7 +581,8 @@ "output": "3e-05", "cache_read": "5e-07" }, - "provider_type": "openai" + "provider_type": "openai", + "wire_api": "responses" }, "gpt-5.2-chat-latest": { "cost": { @@ -693,7 +695,8 @@ "input": "3e-05", "output": "0.00018" }, - "provider_type": "openai" + "provider_type": "openai", + "wire_api": "responses" }, "o4-mini-deep-research": { "cost": {