diff --git a/.github/drivers/copilot_sdk_driver_sample_node.cjs b/.github/drivers/copilot_sdk_driver_sample_node.cjs index fcc3392d4e8..cc20fdac5c3 100644 --- a/.github/drivers/copilot_sdk_driver_sample_node.cjs +++ b/.github/drivers/copilot_sdk_driver_sample_node.cjs @@ -43,15 +43,43 @@ function extractAssistantContent(message) { return ""; } +function isValidProviderConfig(p) { + return p && typeof p.name === "string" && typeof p.type === "string" && typeof p.baseUrl === "string"; +} + +function isValidModelConfig(m) { + return m && typeof m.id === "string" && typeof m.provider === "string"; +} + +function parseMultiProviderJson(raw) { + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object") return null; + if (!Array.isArray(parsed.providers) || parsed.providers.length < 1) return null; + if (!Array.isArray(parsed.models) || parsed.models.length < 1) return null; + // Validate minimal shape: providers must have name/type/baseUrl, models must have id/provider + if (!parsed.providers.every(isValidProviderConfig)) return null; + if (!parsed.models.every(isValidModelConfig)) return null; + const model = typeof parsed.model === "string" ? parsed.model.trim() : ""; + return { model, providers: parsed.providers, models: parsed.models }; + } catch { + return null; + } +} + function buildSessionConfig(model, onPermissionRequest) { const config = { onPermissionRequest, model, }; - const providerBaseUrl = process.env.GH_AW_COPILOT_SDK_PROVIDER_BASE_URL; - if (providerBaseUrl) { - config.provider = { type: "openai", baseUrl: providerBaseUrl }; + // Multi-provider BYOK configuration (preferred) + const multiProviderJson = process.env.GH_AW_COPILOT_SDK_MULTI_PROVIDER_JSON; + const multiProviderConfig = parseMultiProviderJson(multiProviderJson); + if (multiProviderConfig) { + config.providers = multiProviderConfig.providers; + config.models = multiProviderConfig.models; } return config; @@ -96,4 +124,7 @@ if (require.main === module) { module.exports = { buildSessionConfig, + parseMultiProviderJson, + isValidProviderConfig, + isValidModelConfig, }; diff --git a/actions/setup/js/awf_reflect.cjs b/actions/setup/js/awf_reflect.cjs index eaf456d2e37..7d184d26201 100644 --- a/actions/setup/js/awf_reflect.cjs +++ b/actions/setup/js/awf_reflect.cjs @@ -417,7 +417,9 @@ function inferProviderTypeForModel(endpointProvider, modelName, catalogEntryOrMo if (ep === "anthropic") return "anthropic"; if (ep === "azure" || ep === "azure-openai" || ep === "azure_openai") return "azure"; if (ep === "openai") return "openai"; - // For "copilot", "github-copilot", and unknown providers, fall through to model-based lookup. + // GitHub Copilot provider is a multi-model proxy that always uses OpenAI wire protocol. + if (ep === "copilot" || ep === "github-copilot") return "openai"; + // For unknown providers, fall through to model-based lookup. const model = String(modelName || "") .toLowerCase() @@ -479,86 +481,151 @@ function inferWireApiForModel(providerType, modelName, catalogEntryOrModelsJson) } /** - * 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. - * Returns null when no suitable endpoint is found (e.g. no reflect data, or endpoints not - * configured). + * Derive a base URL string from an endpoint object. + * Prefers the origin of `models_url`; falls back to `http://api-proxy:`. + * Returns an empty string when neither is available. * - * Requires live reflect data passed directly via `reflectData`. + * @param {{ models_url?: string | null, port?: number | null }} endpoint + * @returns {string} + */ +function endpointBaseUrl(endpoint) { + if (typeof endpoint.models_url === "string" && endpoint.models_url) { + try { + return new URL(endpoint.models_url).origin; + } catch { + // fall through to port-based construction + } + } + if (endpoint.port != null) { + return `http://api-proxy:${String(endpoint.port)}`; + } + return ""; +} + +/** + * Resolve multi-provider BYOK configuration from AWF /reflect data. + * + * Returns `null` when no configured endpoints are present or the data is + * unavailable. + * + * Each endpoint becomes a `NamedProviderConfig` (using the endpoint's `provider` + * field as the stable name) and every model advertised by that endpoint becomes a + * `ProviderModelConfig` tuple `{ id, provider }` referencing it. Callers can + * derive provider-qualified selection ids as `"/"` if needed. + * + * The primary model is the first model that matches `options.model` (if set), + * otherwise the first model across all providers. * * @param {{ * model?: string, - * provider?: string, * reflectData: object | null | undefined, * modelsJson?: object | null, * logger?: (msg: string) => void, * }} [options] - * @returns {{ model: string, provider: { type: "openai" | "azure" | "anthropic", baseUrl: string, wireApi?: "completions" | "responses" } } | null} + * @returns {{ + * model: string, + * providers: Array<{ name: string, type: "openai" | "azure" | "anthropic", baseUrl: string, wireApi?: "completions" | "responses" }>, + * models: Array<{ id: string, provider: string }>, + * } | null} */ -function resolveCopilotSDKCustomProviderFromReflect(options) { +function resolveMultiProviderFromReflect(options) { const configuredModel = typeof options?.model === "string" ? options.model.trim() : ""; - const configuredProvider = typeof options?.provider === "string" ? options.provider.trim().toLowerCase() : ""; 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"); + logger("sdk-mode(multi): no reflect data provided; cannot resolve multi-provider config"); 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"); + logger(`sdk-mode(multi): no configured endpoints in awf-reflect data; cannot build multi-provider config`); return null; } - const endpoint = - (configuredModel ? endpoints.find(ep => Array.isArray(ep.models) && ep.models.includes(configuredModel)) : null) || - (configuredProvider ? endpoints.find(ep => String(ep.provider || "").toLowerCase() === configuredProvider) : null) || - endpoints.find(ep => String(ep.provider || "").toLowerCase() === "copilot") || - endpoints[0]; + /** @type {Array<{ name: string, type: "openai" | "azure" | "anthropic", baseUrl: string, wireApi?: "completions" | "responses" }>} */ + const providers = []; + /** @type {Array<{ id: string, provider: string }>} */ + const models = []; + + // Track used provider names to avoid duplicates when multiple endpoints share the same + // provider label (e.g. two "copilot" entries at different ports). + /** @type {Map} */ + const providerNameCount = new Map(); + + for (const endpoint of endpoints) { + const baseUrl = endpointBaseUrl(endpoint); + if (!baseUrl) { + logger(`sdk-mode(multi): skipping endpoint with no resolvable baseUrl (provider=${String(endpoint.provider || "unknown")})`); + continue; + } - 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 + const rawProviderName = String(endpoint.provider || "").trim(); + if (!rawProviderName) { + logger("sdk-mode(multi): skipping endpoint with no provider name"); + continue; + } + + // Ensure unique provider names by appending a suffix when the same name appears twice. + const existing = providerNameCount.get(rawProviderName) ?? 0; + providerNameCount.set(rawProviderName, existing + 1); + const providerName = existing === 0 ? rawProviderName : `${rawProviderName}-${existing}`; + + const endpointModels = Array.isArray(endpoint.models) ? endpoint.models.filter(m => typeof m === "string" && m.trim().length > 0) : []; + + // Infer provider type and wire API using the configured model if available, + // otherwise fall back to the first model. + // For multi-model providers (e.g. Copilot), different models may have different wire APIs, + // so we prefer the configured model to ensure the correct wireApi is selected. + const firstModel = endpointModels.length > 0 ? endpointModels[0] : ""; + const modelForInference = configuredModel && endpointModels.includes(configuredModel) ? configuredModel : firstModel; + const catalogProviderName = rawProviderName.toLowerCase() === "copilot" ? "github-copilot" : rawProviderName; + const catalogEntry = modelForInference ? getCatalogModelEntry(options?.modelsJson ?? null, modelForInference, catalogProviderName) : null; + const providerType = inferProviderTypeForModel(rawProviderName, modelForInference, catalogEntry); + const wireApi = inferWireApiForModel(providerType, modelForInference, catalogEntry); + + logger( + `sdk-mode(multi): resolved provider="${providerName}" (raw="${rawProviderName}") type="${providerType}" wireApi="${wireApi || "(none)"}" ` + + `inferredFrom="${modelForInference}" modelCount=${endpointModels.length} baseUrl="${baseUrl}"` + ); + + providers.push({ + name: providerName, + type: providerType, + baseUrl, + ...(wireApi ? { wireApi } : {}), + }); + + for (const modelId of endpointModels) { + models.push({ id: modelId, provider: providerName }); } } - 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"); + + if (providers.length === 0) { + logger("sdk-mode(multi): no providers resolved from awf-reflect data; cannot build multi-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() : ""; + // Determine the primary model: prefer the configured model if it appears in the model list; + // otherwise fall back to the first model across all providers. + let primaryModel = ""; + if (configuredModel) { + const match = models.find(m => m.id === configuredModel); + if (match) primaryModel = match.id; } - if (!model) { - logger("sdk-mode: unable to derive model for custom provider from awf-reflect; cannot resolve custom provider"); + if (!primaryModel && models.length > 0) { + primaryModel = models[0].id; + } + + if (!primaryModel) { + logger("sdk-mode(multi): no models found in awf-reflect endpoints; cannot build multi-provider config"); return null; } - const endpointProvider = String(endpoint.provider || ""); - const catalogProviderName = - String(endpointProvider || "") - .toLowerCase() - .trim() === "copilot" - ? "github-copilot" - : endpointProvider; - const catalogEntry = getCatalogModelEntry(options?.modelsJson ?? null, model, catalogProviderName); - const providerType = inferProviderTypeForModel(endpointProvider, model, catalogEntry); - const wireApi = inferWireApiForModel(providerType, model, catalogEntry); - logger(`sdk-mode: custom provider resolved from awf-reflect (provider=${String(endpoint.provider || "unknown")} type=${providerType} baseUrl=${baseUrl} model=${model}${wireApi ? ` wireApi=${wireApi}` : ""})`); - return { - model, - provider: { type: providerType, baseUrl, ...(wireApi ? { wireApi } : {}) }, - }; + logger(`sdk-mode(multi): resolved ${providers.length} providers, ${models.length} models (primary model: ${primaryModel})`); + return { model: primaryModel, providers, models }; } if (typeof module !== "undefined" && module.exports) { @@ -578,6 +645,6 @@ if (typeof module !== "undefined" && module.exports) { getCatalogModelEntry, inferProviderTypeForModel, inferWireApiForModel, - resolveCopilotSDKCustomProviderFromReflect, + resolveMultiProviderFromReflect, }; } diff --git a/actions/setup/js/awf_reflect.test.cjs b/actions/setup/js/awf_reflect.test.cjs index 22e8ac4b468..51317b2e128 100644 --- a/actions/setup/js/awf_reflect.test.cjs +++ b/actions/setup/js/awf_reflect.test.cjs @@ -21,7 +21,7 @@ const { getCatalogModelEntry, inferProviderTypeForModel, inferWireApiForModel, - resolveCopilotSDKCustomProviderFromReflect, + resolveMultiProviderFromReflect, } = require("./awf_reflect.cjs"); describe("awf_reflect.cjs", () => { @@ -349,16 +349,25 @@ describe("awf_reflect.cjs", () => { expect(inferProviderTypeForModel("openai", "gpt-4o", null)).toBe("openai"); }); - it("uses model name heuristic for claude-* models on copilot endpoint", () => { - expect(inferProviderTypeForModel("copilot", "claude-sonnet-4.6", null)).toBe("anthropic"); - expect(inferProviderTypeForModel("copilot", "claude-opus-4-5", null)).toBe("anthropic"); + it("uses explicit copilot provider mapping (always openai)", () => { + // GitHub Copilot provider is a multi-model proxy that always uses OpenAI wire protocol, + // regardless of model name (even for claude/anthropic models) + expect(inferProviderTypeForModel("copilot", "claude-sonnet-4.6", null)).toBe("openai"); + expect(inferProviderTypeForModel("copilot", "claude-opus-4-5", null)).toBe("openai"); + expect(inferProviderTypeForModel("github-copilot", "claude-haiku-4.5", null)).toBe("openai"); + // Non-copilot providers still use model name heuristics expect(inferProviderTypeForModel("", "claude-haiku-4.5", null)).toBe("anthropic"); }); - it("uses model name heuristic for opus/haiku/sonnet suffix models", () => { - expect(inferProviderTypeForModel("copilot", "model-opus-4.6", null)).toBe("anthropic"); - expect(inferProviderTypeForModel("copilot", "model-haiku-4.5", null)).toBe("anthropic"); - expect(inferProviderTypeForModel("copilot", "model-sonnet-4", null)).toBe("anthropic"); + it("uses model name heuristic for opus/haiku/sonnet suffix models when provider is not copilot", () => { + // copilot provider always returns openai + expect(inferProviderTypeForModel("copilot", "model-opus-4.6", null)).toBe("openai"); + expect(inferProviderTypeForModel("copilot", "model-haiku-4.5", null)).toBe("openai"); + expect(inferProviderTypeForModel("copilot", "model-sonnet-4", null)).toBe("openai"); + // Non-copilot providers use model name heuristics + expect(inferProviderTypeForModel("", "model-opus-4.6", null)).toBe("anthropic"); + expect(inferProviderTypeForModel("", "model-haiku-4.5", null)).toBe("anthropic"); + expect(inferProviderTypeForModel("", "model-sonnet-4", null)).toBe("anthropic"); }); it("uses model name heuristic for gpt-* models", () => { @@ -372,7 +381,7 @@ describe("awf_reflect.cjs", () => { expect(inferProviderTypeForModel("copilot", "o4-mini", null)).toBe("openai"); }); - it("looks up provider_type from modelsJson catalog", () => { + it("copilot provider always returns openai, even for anthropic models in catalog", () => { const modelsJson = { providers: { "github-copilot": { @@ -384,12 +393,14 @@ describe("awf_reflect.cjs", () => { }, }; expect(inferProviderTypeForModel("copilot", "raptor-mini", modelsJson)).toBe("openai"); - expect(inferProviderTypeForModel("copilot", "claude-sonnet-4", modelsJson)).toBe("anthropic"); + // copilot provider mapping takes precedence over catalog provider_type + expect(inferProviderTypeForModel("copilot", "claude-sonnet-4", modelsJson)).toBe("openai"); }); - it("falls back to heuristics when model is not in catalog", () => { + it("copilot provider always returns openai, even for anthropic model name heuristics", () => { const modelsJson = { providers: { "github-copilot": { models: {} } } }; - expect(inferProviderTypeForModel("copilot", "claude-unknown-model", modelsJson)).toBe("anthropic"); + // copilot provider mapping takes precedence over model name heuristics + expect(inferProviderTypeForModel("copilot", "claude-unknown-model", modelsJson)).toBe("openai"); }); it("returns 'openai' by default for unknown models", () => { @@ -464,172 +475,160 @@ describe("awf_reflect.cjs", () => { }); }); - 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", wireApi: "completions" }, + describe("resolveMultiProviderFromReflect", () => { + it("returns null when reflectData is null", () => { + const logs = []; + const result = resolveMultiProviderFromReflect({ reflectData: null, logger: msg => logs.push(msg) }); + expect(result).toBeNull(); + expect(logs.some(l => l.includes("no reflect data provided"))).toBe(true); + }); + + it("resolves with a single configured endpoint", () => { + const result = resolveMultiProviderFromReflect({ + reflectData: { endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["gpt-5.4"] }] }, + }); + expect(result).not.toBeNull(); + expect(result.providers).toHaveLength(1); + expect(result.model).toBe("gpt-5.4"); + }); + + it("returns null when no configured endpoints exist", () => { + const result = resolveMultiProviderFromReflect({ + reflectData: { + endpoints: [ + { provider: "openai", port: 10001, configured: false, models: ["gpt-4o"] }, + { provider: "anthropic", port: 10002, configured: false, models: ["claude-sonnet-4.6"] }, + ], + }, }); + expect(result).toBeNull(); }); - it("prefers the endpoint matching the configured model", () => { + it("builds providers and models from two configured endpoints", () => { 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: "anthropic", baseUrl: "http://api-proxy:10002" }, - }); - }); - - it("prefers the endpoint matching the configured provider when model is unset", () => { + const result = resolveMultiProviderFromReflect({ reflectData }); + expect(result).not.toBeNull(); + expect(result.providers).toHaveLength(2); + expect(result.models).toHaveLength(2); + expect(result.providers[0]).toMatchObject({ name: "openai", type: "openai", baseUrl: "http://api-proxy:10001", wireApi: "completions" }); + expect(result.providers[1]).toMatchObject({ name: "anthropic", type: "anthropic", baseUrl: "http://api-proxy:10002" }); + expect(result.providers[1]).not.toHaveProperty("wireApi"); + expect(result.models[0]).toEqual({ id: "gpt-4o", provider: "openai" }); + expect(result.models[1]).toEqual({ id: "claude-sonnet-4.6", provider: "anthropic" }); + }); + + it("sets primary model to first model when no configured model provided", () => { const reflectData = { endpoints: [ - { provider: "copilot", port: 10002, configured: true, models: ["claude-sonnet-4.6"] }, - { provider: "anthropic", port: 10003, configured: true, models: ["claude-sonnet-4.6"] }, + { provider: "openai", port: 10001, configured: true, models: ["gpt-5.4"] }, + { provider: "anthropic", port: 10002, configured: true, models: ["claude-sonnet-4.6"] }, ], }; - expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData, provider: "anthropic" })).toEqual({ - model: "claude-sonnet-4.6", - provider: { type: "anthropic", baseUrl: "http://api-proxy:10003" }, - }); + const result = resolveMultiProviderFromReflect({ reflectData }); + expect(result.model).toBe("gpt-5.4"); }); - it("derives baseUrl from models_url origin when available", () => { + it("prefers the configured model when it appears in model list", () => { 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", wireApi: "completions" }, - }); - }); - - it("uses anthropic type for anthropic endpoint serving claude model", () => { - const reflectData = { - endpoints: [{ provider: "anthropic", port: 10001, configured: true, models: ["claude-sonnet-4.6"] }], + endpoints: [ + { provider: "openai", port: 10001, configured: true, models: ["gpt-5.4"] }, + { provider: "anthropic", port: 10002, configured: true, models: ["claude-sonnet-4.6"] }, + ], }; - expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData })).toEqual({ - model: "claude-sonnet-4.6", - provider: { type: "anthropic", baseUrl: "http://api-proxy:10001" }, - }); + const result = resolveMultiProviderFromReflect({ reflectData, model: "claude-sonnet-4.6" }); + expect(result.model).toBe("claude-sonnet-4.6"); }); - it("uses anthropic type via model name heuristic on copilot endpoint when claude model is selected", () => { + it("falls back to first model when configured model is not found in list", () => { const reflectData = { - endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["claude-opus-4.5", "gpt-5.4"] }], + endpoints: [ + { provider: "openai", port: 10001, configured: true, models: ["gpt-5.4"] }, + { provider: "anthropic", port: 10002, configured: true, models: ["claude-sonnet-4.6"] }, + ], }; - expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData, model: "claude-opus-4.5" })).toEqual({ - model: "claude-opus-4.5", - provider: { type: "anthropic", baseUrl: "http://api-proxy:10002" }, - }); + const result = resolveMultiProviderFromReflect({ reflectData, model: "nonexistent-model" }); + expect(result.model).toBe("gpt-5.4"); }); - it("uses openai type via model name heuristic on copilot endpoint when gpt model is selected", () => { + it("derives provider baseUrl from models_url origin when available", () => { const reflectData = { - endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["claude-opus-4.5", "gpt-5.4"] }], + endpoints: [ + { provider: "openai", port: 10001, configured: true, models: ["gpt-4o"], models_url: "http://172.30.0.10:10001/v1/models" }, + { provider: "anthropic", port: 10002, configured: true, models: ["claude-sonnet-4.6"], models_url: "http://172.30.0.11:10002/v1/models" }, + ], }; - expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData, model: "gpt-5.4" })).toEqual({ - model: "gpt-5.4", - provider: { type: "openai", baseUrl: "http://api-proxy:10002", wireApi: "completions" }, - }); + const result = resolveMultiProviderFromReflect({ reflectData }); + expect(result.providers[0].baseUrl).toBe("http://172.30.0.10:10001"); + expect(result.providers[1].baseUrl).toBe("http://172.30.0.11:10002"); }); - it("uses modelsJson catalog for provider_type lookup", () => { + it("infers openai wireApi from modelsJson catalog for openai endpoint", () => { const reflectData = { - endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["raptor-mini"] }], + endpoints: [ + { provider: "copilot", port: 10002, configured: true, models: ["gpt-5.5"] }, + { provider: "anthropic", port: 10003, configured: true, models: ["claude-sonnet-4.6"] }, + ], }; const modelsJson = { providers: { - "github-copilot": { models: { "raptor-mini": { provider_type: "openai", cost: {} } } }, + "github-copilot": { models: { "gpt-5.5": { provider_type: "openai", wire_api: "responses", cost: {} } } }, }, }; - expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData, modelsJson })).toEqual({ - model: "raptor-mini", - provider: { type: "openai", baseUrl: "http://api-proxy:10002", wireApi: "completions" }, - }); + const result = resolveMultiProviderFromReflect({ reflectData, modelsJson }); + expect(result.providers[0]).toMatchObject({ name: "copilot", type: "openai", wireApi: "responses" }); + expect(result.providers[1]).toMatchObject({ name: "anthropic", type: "anthropic" }); + expect(result.providers[1]).not.toHaveProperty("wireApi"); }); - it("uses wire_api from modelsJson when provided", () => { + it("handles duplicate provider names by appending a numeric suffix", () => { const reflectData = { - endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["gpt-5.5"] }], - }; - const modelsJson = { - providers: { - "github-copilot": { models: { "gpt-5.5": { provider_type: "openai", wire_api: "responses", cost: {} } } }, - }, + endpoints: [ + { provider: "copilot", port: 10001, configured: true, models: ["gpt-5.4"] }, + { provider: "copilot", port: 10002, configured: true, models: ["gpt-5.5"] }, + ], }; - expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData, model: "gpt-5.5", modelsJson })).toEqual({ - model: "gpt-5.5", - provider: { type: "openai", baseUrl: "http://api-proxy:10002", wireApi: "responses" }, - }); + const result = resolveMultiProviderFromReflect({ reflectData }); + expect(result.providers[0].name).toBe("copilot"); + expect(result.providers[1].name).toBe("copilot-1"); + expect(result.models[0]).toEqual({ id: "gpt-5.4", provider: "copilot" }); + expect(result.models[1]).toEqual({ id: "gpt-5.5", provider: "copilot-1" }); }); - it("prefers github-copilot catalog metadata when duplicate model names exist across providers", () => { + it("skips endpoints with no resolvable baseUrl", () => { + const logs = []; const reflectData = { - endpoints: [{ provider: "copilot", port: 10002, configured: true, models: ["gpt-5.5"] }], - }; - const modelsJson = { - providers: { - openai: { models: { "gpt-5.5": { provider_type: "openai", cost: {} } } }, - "github-copilot": { models: { "gpt-5.5": { provider_type: "openai", wire_api: "responses", cost: {} } } }, - }, + endpoints: [ + { provider: "openai", port: 10001, configured: true, models: ["gpt-4o"] }, + // no port and no models_url — skipped + { provider: "anthropic", configured: true, models: ["claude-sonnet-4.6"] }, + { provider: "azure", port: 10003, configured: true, models: ["gpt-4o-azure"] }, + ], }; - expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData, model: "gpt-5.5", modelsJson })).toEqual({ - model: "gpt-5.5", - provider: { type: "openai", baseUrl: "http://api-proxy:10002", wireApi: "responses" }, - }); + const result = resolveMultiProviderFromReflect({ reflectData, logger: msg => logs.push(msg) }); + expect(result).not.toBeNull(); + expect(result.providers).toHaveLength(2); + expect(result.providers.map(p => p.name)).toEqual(["openai", "azure"]); + expect(logs.some(l => l.includes("no resolvable baseUrl"))).toBe(true); }); - it("omits wireApi for Anthropic models even when the catalog has wire_api", () => { + it("collects all models from all endpoints", () => { const reflectData = { - endpoints: [{ provider: "anthropic", port: 10001, configured: true, models: ["claude-opus-5"] }], - }; - const modelsJson = { - providers: { - "github-copilot": { models: { "claude-opus-5": { provider_type: "anthropic", wire_api: "responses", cost: {} } } }, - }, + endpoints: [ + { provider: "openai", port: 10001, configured: true, models: ["gpt-4o", "gpt-5.4"] }, + { provider: "anthropic", port: 10002, configured: true, models: ["claude-sonnet-4.6", "claude-opus-5"] }, + ], }; - expect(resolveCopilotSDKCustomProviderFromReflect({ reflectData, model: "claude-opus-5", modelsJson })).toEqual({ - model: "claude-opus-5", - provider: { type: "anthropic", baseUrl: "http://api-proxy:10001" }, - }); - }); - - 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); + const result = resolveMultiProviderFromReflect({ reflectData }); + expect(result.models).toHaveLength(4); + expect(result.models).toContainEqual({ id: "gpt-4o", provider: "openai" }); + expect(result.models).toContainEqual({ id: "gpt-5.4", provider: "openai" }); + expect(result.models).toContainEqual({ id: "claude-sonnet-4.6", provider: "anthropic" }); + expect(result.models).toContainEqual({ id: "claude-opus-5", provider: "anthropic" }); }); }); }); diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 144cc16e005..0ead97601d6 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -57,7 +57,7 @@ const { fetchAWFReflect, fetchModelsFromUrl, inferProviderTypeForModel, - resolveCopilotSDKCustomProviderFromReflect, + resolveMultiProviderFromReflect, } = require("./awf_reflect.cjs"); const { runSafeOutputsCLI, buildMissingToolAlternatives, emitMissingToolPermissionIssue, emitInfrastructureIncomplete, hasExpectedSafeOutputs, hasNoopInSafeOutputs } = require("./safeoutputs_cli.cjs"); const { countPermissionDeniedIssues, hasNumerousPermissionDeniedIssues, extractDeniedCommands, buildMissingToolPermissionIssuePayload } = require("./permission_denied_helpers.cjs"); @@ -499,6 +499,11 @@ function detectCopilotErrors(output) { /** * Build child-process environment additions for Copilot SDK mode. + * + * When `multiProviderJson` is set, the driver will use multi-provider BYOK. + * `COPILOT_PROVIDER_*` env vars are still populated from the primary provider + * for the headless sidecar (sub-agent sessions). + * * @param {{ * sdkEnv: NodeJS.ProcessEnv, * copilotSDKMode: boolean, @@ -507,19 +512,18 @@ function detectCopilotErrors(output) { * providerType: string, * providerWireApi: string, * resolvedModel: string, + * multiProviderJson?: string, * }} options * @returns {NodeJS.ProcessEnv} */ -function buildCopilotSDKChildEnv({ sdkEnv, copilotSDKMode, copilotConnectionToken, providerBaseUrl, providerType, providerWireApi, resolvedModel }) { +function buildCopilotSDKChildEnv({ sdkEnv, copilotSDKMode, copilotConnectionToken, providerBaseUrl, providerType, providerWireApi, resolvedModel, multiProviderJson }) { if (!copilotSDKMode) { return sdkEnv; } return { ...sdkEnv, COPILOT_CONNECTION_TOKEN: copilotConnectionToken, - GH_AW_COPILOT_SDK_PROVIDER_BASE_URL: providerBaseUrl, - GH_AW_COPILOT_SDK_PROVIDER_TYPE: providerType, - ...(providerWireApi ? { GH_AW_COPILOT_SDK_PROVIDER_WIRE_API: providerWireApi } : {}), + ...(multiProviderJson ? { GH_AW_COPILOT_SDK_MULTI_PROVIDER_JSON: multiProviderJson } : {}), COPILOT_MODEL: resolvedModel, // Native Copilot CLI BYOK env vars — consumed by the headless sidecar for all sessions. COPILOT_PROVIDER_BASE_URL: providerBaseUrl, @@ -746,27 +750,42 @@ async function main() { } } - // 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. + // Resolve BYOK provider from live reflect data (SDK mode only). + // Multi-provider BYOK is the only supported mode — fail immediately if the + // provider cannot be resolved so retries are not wasted on a misconfigured environment. let providerBaseUrl = ""; let providerType = "openai"; let providerWireApi = ""; let resolvedModel = ""; + let multiProviderJson = ""; if (copilotSDKMode) { const configuredModel = process.env.COPILOT_MODEL || ""; - const configuredProvider = process.env.GH_AW_LLM_PROVIDER || ""; const modelsJson = loadModelsJson(); - const customProvider = resolveCopilotSDKCustomProviderFromReflect({ model: configuredModel, provider: configuredProvider, reflectData: awfReflectData, modelsJson, logger: log }); - if (!customProvider) { + + const multiProvider = resolveMultiProviderFromReflect({ model: configuredModel, reflectData: awfReflectData, modelsJson, logger: log }); + if (!multiProvider) { 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; - providerType = customProvider.provider.type || "openai"; - providerWireApi = customProvider.provider.wireApi || ""; - resolvedModel = customProvider.model; - log(`copilot-sdk driver mode: BYOK provider resolved (baseUrl=${providerBaseUrl} type=${providerType}${providerWireApi ? ` wireApi=${providerWireApi}` : ""} model=${resolvedModel})`); + resolvedModel = multiProvider.model; + multiProviderJson = JSON.stringify({ model: multiProvider.model, providers: multiProvider.providers, models: multiProvider.models }); + // Set the primary provider's details as COPILOT_PROVIDER_* env vars for the headless sidecar + // (which still reads those to configure its own sub-agent sessions). + const primaryProviderName = multiProvider.models.find(m => m.id === resolvedModel)?.provider ?? multiProvider.providers[0]?.name; + const primaryProvider = multiProvider.providers.find(p => p.name === primaryProviderName) ?? multiProvider.providers[0]; + providerBaseUrl = primaryProvider?.baseUrl ?? ""; + providerType = primaryProvider?.type ?? "openai"; + providerWireApi = primaryProvider?.wireApi ?? ""; + + // For BYOK copilot providers, prefix the model with "copilot/" so subagents treat it as BYOK. + // The headless sidecar reads COPILOT_MODEL to configure sub-agent sessions spawned via the task tool, + // and the "copilot/" prefix signals to use the custom provider config from COPILOT_PROVIDER_* env vars. + const isCopilotProvider = primaryProviderName && (primaryProviderName.toLowerCase().includes("copilot") || primaryProviderName.toLowerCase().includes("github-copilot")); + if (isCopilotProvider && resolvedModel && !resolvedModel.includes("/")) { + resolvedModel = `copilot/${resolvedModel}`; + } + + log(`copilot-sdk driver mode: multi-provider config resolved (${multiProvider.providers.length} providers, ${multiProvider.models.length} models, model=${resolvedModel})`); } // Merge SDK env additions into the child process env only when the SDK helper @@ -775,12 +794,10 @@ async function main() { // 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, type, and model so the driver - // subprocess does not need to re-read the reflect file. // - // Additionally, forward BYOK config as native Copilot CLI COPILOT_PROVIDER_* env vars so + // Forward BYOK config as native Copilot CLI COPILOT_PROVIDER_* env vars so // the headless sidecar propagates the same provider to sub-agent sessions spawned via the - // task tool. Sub-agents do not inherit the SDK session-level `provider` config; the headless + // task tool. Sub-agents do not inherit the SDK session-level `providers` config; the headless // server instead reads COPILOT_PROVIDER_* from its own process env to configure each // sub-agent session's inference backend. const sdkChildEnv = buildCopilotSDKChildEnv({ @@ -791,6 +808,7 @@ async function main() { providerType, providerWireApi, resolvedModel, + multiProviderJson, }); const childEnv = Object.keys(sdkChildEnv).length > 0 ? { ...process.env, ...sdkChildEnv } : undefined; @@ -1156,7 +1174,7 @@ if (typeof module !== "undefined" && module.exports) { isHTTP400ResponseError, isModelAvailableInReflectData, isModelAvailableInReflectFile, - resolveCopilotSDKCustomProviderFromReflect, + resolveMultiProviderFromReflect, inferProviderTypeForModel, countPermissionDeniedIssues, detectCopilotErrors, diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index 30dcbbbaba7..374da2764b2 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -37,7 +37,6 @@ const { isMCPGatewayShutdownError, isModelAvailableInReflectData, isModelAvailableInReflectFile, - resolveCopilotSDKCustomProviderFromReflect, inferProviderTypeForModel, enrichReflectModels, extractModelIds, @@ -185,7 +184,8 @@ describe("copilot_harness.cjs", () => { }); describe("buildCopilotSDKChildEnv", () => { - it("includes native and gh-aw provider vars when wireApi is configured", () => { + it("includes native sidecar provider vars and multiProviderJson when wireApi is configured", () => { + const multiProviderJson = JSON.stringify({ model: "gpt-5.4", providers: [{ name: "copilot", type: "openai", baseUrl: "http://api-proxy:10002", wireApi: "completions" }], models: [{ id: "gpt-5.4", provider: "copilot" }] }); const env = buildCopilotSDKChildEnv({ sdkEnv: { COPILOT_SDK_URI: "http://127.0.0.1:4000" }, copilotSDKMode: true, @@ -194,19 +194,21 @@ describe("copilot_harness.cjs", () => { providerType: "openai", providerWireApi: "completions", resolvedModel: "gpt-5.4", + multiProviderJson, }); expect(env).toMatchObject({ COPILOT_SDK_URI: "http://127.0.0.1:4000", COPILOT_CONNECTION_TOKEN: "token-123", - GH_AW_COPILOT_SDK_PROVIDER_BASE_URL: "http://api-proxy:10002", - GH_AW_COPILOT_SDK_PROVIDER_TYPE: "openai", - GH_AW_COPILOT_SDK_PROVIDER_WIRE_API: "completions", + GH_AW_COPILOT_SDK_MULTI_PROVIDER_JSON: multiProviderJson, COPILOT_MODEL: "gpt-5.4", COPILOT_PROVIDER_BASE_URL: "http://api-proxy:10002", COPILOT_PROVIDER_TYPE: "openai", COPILOT_PROVIDER_WIRE_API: "completions", }); + expect(env).not.toHaveProperty("GH_AW_COPILOT_SDK_PROVIDER_BASE_URL"); + expect(env).not.toHaveProperty("GH_AW_COPILOT_SDK_PROVIDER_TYPE"); + expect(env).not.toHaveProperty("GH_AW_COPILOT_SDK_PROVIDER_WIRE_API"); }); it("omits wireApi vars when wireApi is empty", () => { @@ -220,7 +222,6 @@ describe("copilot_harness.cjs", () => { resolvedModel: "gpt-5.4", }); - expect(env.GH_AW_COPILOT_SDK_PROVIDER_WIRE_API).toBeUndefined(); expect(env.COPILOT_PROVIDER_WIRE_API).toBeUndefined(); }); }); @@ -1759,16 +1760,6 @@ describe("copilot_harness.cjs", () => { fs.unlinkSync(reflectFile); } }); - - it("derives SDK custom provider and model from reflect data", () => { - 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", wireApi: "completions" }, - }); - }); }); describe("enrichReflectModels", () => { diff --git a/actions/setup/js/copilot_sdk_driver.cjs b/actions/setup/js/copilot_sdk_driver.cjs index 4167f8106fa..21b2797fc67 100644 --- a/actions/setup/js/copilot_sdk_driver.cjs +++ b/actions/setup/js/copilot_sdk_driver.cjs @@ -7,14 +7,13 @@ * configuration from environment variables, delegates the full session * lifecycle to copilot_sdk_session.cjs, and exits with the session's exit code. * - * GH_AW_PROMPT — path to the prompt file - * COPILOT_SDK_URI — SDK server URI (set by the harness) - * COPILOT_CONNECTION_TOKEN — shared secret for the SDK session (set by the harness) - * 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) + * GH_AW_PROMPT — path to the prompt file + * COPILOT_SDK_URI — SDK server URI (set by the harness) + * COPILOT_CONNECTION_TOKEN — shared secret for the SDK session (set by the harness) + * COPILOT_MODEL — model override (optional) + * GH_AW_COPILOT_SDK_MULTI_PROVIDER_JSON — JSON-encoded multi-provider config (required). + * Shape: { model, providers: NamedProviderConfig[], models: ProviderModelConfig[] } + * 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 * client connection, runs the session, and exits. @@ -32,7 +31,7 @@ const { parsePermissionConfigFromServerArgs } = require("./copilot_sdk_permissio // Re-export the session and permission helpers so that existing callers that // require("./copilot_sdk_driver.cjs") (e.g. copilot_harness.cjs) continue to work. -module.exports = { extractPromptFromArgs, runWithCopilotSDK, parsePermissionConfigFromServerArgs, parseWireApiEnv }; +module.exports = { extractPromptFromArgs, runWithCopilotSDK, parsePermissionConfigFromServerArgs, parseMultiProviderJson }; // --------------------------------------------------------------------------- // Standalone entry point @@ -46,17 +45,43 @@ function log(msg) { process.stderr.write(`[copilot-sdk-driver] ${msg}\n`); } +function isValidProviderConfig(p) { + return p && typeof p.name === "string" && typeof p.type === "string" && typeof p.baseUrl === "string"; +} + +function isValidModelConfig(m) { + return m && typeof m.id === "string" && typeof m.provider === "string"; +} + /** - * Normalize the optional provider wire API env var. + * Parse the GH_AW_COPILOT_SDK_MULTI_PROVIDER_JSON env var. + * + * Returns `null` when the env var is unset or contains invalid JSON. + * On success returns `{ model, providers, models }` where the shapes match the + * Copilot SDK `NamedProviderConfig` / `ProviderModelConfig` types. * - * @param {string | undefined} raw - * @returns {"completions" | "responses" | undefined} + * @param {string | undefined} value + * @returns {{ + * model: string, + * providers: import("@github/copilot-sdk").NamedProviderConfig[], + * models: import("@github/copilot-sdk").ProviderModelConfig[], + * } | null} */ -function parseWireApiEnv(raw) { - const normalized = String(raw || "") - .toLowerCase() - .trim(); - return normalized === "responses" || normalized === "completions" ? normalized : undefined; +function parseMultiProviderJson(value) { + if (!value) return null; + try { + const parsed = JSON.parse(value); + if (!parsed || typeof parsed !== "object") return null; + if (!Array.isArray(parsed.providers) || parsed.providers.length < 1) return null; + if (!Array.isArray(parsed.models) || parsed.models.length < 1) return null; + // Validate minimal shape: providers must have name/type/baseUrl, models must have id/provider + if (!parsed.providers.every(isValidProviderConfig)) return null; + if (!parsed.models.every(isValidModelConfig)) return null; + const model = typeof parsed.model === "string" ? parsed.model.trim() : ""; + return { model, providers: parsed.providers, models: parsed.models }; + } catch { + return null; + } } /** @@ -83,7 +108,6 @@ async function main() { process.exit(1); } - const model = process.env.COPILOT_MODEL || undefined; const connectionToken = process.env.COPILOT_CONNECTION_TOKEN; if (!connectionToken) { process.stderr.write("[copilot-sdk-driver] error: COPILOT_CONNECTION_TOKEN is required. This token is generated by copilot_harness.cjs and must be passed to the driver environment\n"); @@ -102,23 +126,25 @@ 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 and - // GH_AW_COPILOT_SDK_PROVIDER_TYPE. - // 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) { - 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"); + // --- Resolve provider configuration from environment ----------------- + // The harness injects GH_AW_COPILOT_SDK_MULTI_PROVIDER_JSON before launching + // this driver. Multi-provider BYOK is the only supported mode. + + const multiProviderConfig = parseMultiProviderJson(process.env.GH_AW_COPILOT_SDK_MULTI_PROVIDER_JSON); + if (!multiProviderConfig) { + process.stderr.write("[copilot-sdk-driver] error: GH_AW_COPILOT_SDK_MULTI_PROVIDER_JSON is not set or invalid — " + "ensure the harness resolved multi-provider config from awf-reflect data\n"); process.exit(1); } - const rawProviderType = process.env.GH_AW_COPILOT_SDK_PROVIDER_TYPE || "openai"; - /** @type {"openai" | "azure" | "anthropic"} */ - const providerType = rawProviderType === "anthropic" || rawProviderType === "azure" ? rawProviderType : "openai"; - log(`provider type: ${providerType}`); - const wireApi = parseWireApiEnv(process.env.GH_AW_COPILOT_SDK_PROVIDER_WIRE_API); - /** @type {import("@github/copilot-sdk").ProviderConfig} */ - const provider = { type: providerType, baseUrl: providerBaseUrl, ...(wireApi ? { wireApi } : {}) }; + + /** @type {import("@github/copilot-sdk").NamedProviderConfig[]} */ + const providers = multiProviderConfig.providers; + /** @type {import("@github/copilot-sdk").ProviderModelConfig[]} */ + const sdkModels = multiProviderConfig.models; + let model = process.env.COPILOT_MODEL || multiProviderConfig.model || undefined; + log(`multi-provider mode: ${providers.length} providers, ${sdkModels.length} models, model=${model ?? "(env)"}`); + for (const p of providers) { + log(` provider: name=${p.name} type=${p.type} baseUrl=${p.baseUrl}${p.wireApi ? ` wireApi=${p.wireApi}` : ""}`); + } // --- Build permission config from sidecar server args ---------------- // GH_AW_COPILOT_SDK_SERVER_ARGS holds the JSON-encoded --allow-tool flags @@ -145,7 +171,8 @@ async function main() { logger: log, model, connectionToken, - provider, + providers, + models: sdkModels, permissionConfig, }); diff --git a/actions/setup/js/copilot_sdk_driver.test.cjs b/actions/setup/js/copilot_sdk_driver.test.cjs index 8f5565dbc4e..052657c3020 100644 --- a/actions/setup/js/copilot_sdk_driver.test.cjs +++ b/actions/setup/js/copilot_sdk_driver.test.cjs @@ -5,7 +5,7 @@ import * as os from "os"; import * as path from "path"; const require = createRequire(import.meta.url); -const { runWithCopilotSDK, parsePermissionConfigFromServerArgs, parseWireApiEnv } = require("./copilot_sdk_driver.cjs"); +const { runWithCopilotSDK, parsePermissionConfigFromServerArgs } = require("./copilot_sdk_driver.cjs"); describe("copilot_sdk_driver.cjs", () => { let testSessionStateDir; @@ -21,19 +21,6 @@ describe("copilot_sdk_driver.cjs", () => { if (testSessionStateDir) fs.rmSync(testSessionStateDir, { recursive: true, force: true }); }); - describe("parseWireApiEnv", () => { - it("accepts supported values case-insensitively", () => { - expect(parseWireApiEnv(" responses ")).toBe("responses"); - expect(parseWireApiEnv("COMPLETIONS")).toBe("completions"); - }); - - it("returns undefined for empty or unsupported values", () => { - expect(parseWireApiEnv("")).toBeUndefined(); - expect(parseWireApiEnv("chat")).toBeUndefined(); - expect(parseWireApiEnv(undefined)).toBeUndefined(); - }); - }); - describe("runWithCopilotSDK", () => { it("disconnects session and stops client on success", async () => { const disconnect = vi.fn().mockResolvedValue(undefined); @@ -768,7 +755,7 @@ describe("copilot_sdk_driver.cjs", () => { expect(taskCompleteEvent.data.summary).toBe("Created 3 issues successfully"); }); - it("passes custom provider and model through to SDK createSession", async () => { + it("passes multi-provider config and model through to SDK createSession", async () => { const disconnect = vi.fn().mockResolvedValue(undefined); const stop = vi.fn().mockResolvedValue(undefined); const forUri = vi.fn(() => ({})); @@ -784,12 +771,15 @@ describe("copilot_sdk_driver.cjs", () => { stop = stop; } + const providers = [{ name: "copilot", type: "openai", baseUrl: "http://api-proxy:10002", wireApi: "responses" }]; + const models = [{ id: "gpt-5.4", provider: "copilot" }]; const result = await runWithCopilotSDK({ sdkUri: "http://127.0.0.1:3002", prompt: "test prompt", logger: () => {}, model: "gpt-5.4", - provider: { type: "openai", baseUrl: "http://api-proxy:10002", wireApi: "responses" }, + providers, + models, sdkModule: { CopilotClient: FakeCopilotClient, RuntimeConnection: { forUri }, @@ -801,7 +791,8 @@ describe("copilot_sdk_driver.cjs", () => { expect(createSession).toHaveBeenCalledWith( expect.objectContaining({ model: "gpt-5.4", - provider: { type: "openai", baseUrl: "http://api-proxy:10002", wireApi: "responses" }, + providers, + models, }) ); expect(forUri).toHaveBeenCalledWith("http://127.0.0.1:3002", {}); diff --git a/actions/setup/js/copilot_sdk_session.cjs b/actions/setup/js/copilot_sdk_session.cjs index be2e6aa5586..d8b696ba3c8 100644 --- a/actions/setup/js/copilot_sdk_session.cjs +++ b/actions/setup/js/copilot_sdk_session.cjs @@ -92,7 +92,8 @@ function extractPromptFromArgs(args) { * attempt?: number, * model?: string, * connectionToken?: string, - * provider?: import("@github/copilot-sdk").ProviderConfig, + * providers?: import("@github/copilot-sdk").NamedProviderConfig[], + * models?: import("@github/copilot-sdk").ProviderModelConfig[], * maxToolDenials?: number | string, * permissionConfig?: { * allowAllTools?: boolean, @@ -108,7 +109,7 @@ function extractPromptFromArgs(args) { * }} options * @returns {Promise<{exitCode: number, output: string, hasOutput: boolean, durationMs: number}>} */ -async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, connectionToken, provider, maxToolDenials, permissionConfig, coreLogger, sdkModule, sessionStateBaseDir }) { +async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, connectionToken, providers, models: providerModels, maxToolDenials, permissionConfig, coreLogger, sdkModule, sessionStateBaseDir }) { // Lazy-require to avoid loading the SDK when it is not needed. // The SDK is large and has side-effects on import (worker threads, etc.). const { CopilotClient, RuntimeConnection, approveAll } = sdkModule ?? require("@github/copilot-sdk"); @@ -242,12 +243,15 @@ async function runWithCopilotSDK({ sdkUri, prompt, logger, attempt = 0, model, c workspaceRoot: process.env.GITHUB_WORKSPACE, }); + // Build session config using the multi-provider surface. /** @type {import("@github/copilot-sdk").SessionConfig} */ const sessionConfig = { model: model || process.env.COPILOT_MODEL || undefined, - provider, + providers, + models: providerModels, onPermissionRequest, }; + log(`creating session with model="${sessionConfig.model || "(none)"}" providers=${providers?.length ?? 0} models=${providerModels?.length ?? 0}`); session = await client.createSession(sessionConfig); log(`session created: sessionId=${session.sessionId}`);