diff --git a/docs/providers/github-copilot.md b/docs/providers/github-copilot.md index 3fe84da3599b..7fd0f4b649f4 100644 --- a/docs/providers/github-copilot.md +++ b/docs/providers/github-copilot.md @@ -31,7 +31,7 @@ provider or agent runtime in three different ways. ```bash - openclaw models set github-copilot/claude-sonnet-4.6 + openclaw models set github-copilot/claude-sonnet-5 ``` Or in config: @@ -39,7 +39,7 @@ provider or agent runtime in three different ways. ```json5 { agents: { - defaults: { model: { primary: "github-copilot/claude-sonnet-4.6" } }, + defaults: { model: { primary: "github-copilot/claude-sonnet-5" } }, }, } ``` @@ -204,7 +204,7 @@ back to `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, then `GITHUB_TOKEN`. Use Fresh non-interactive setup validates the token before saving it. When setup must choose a default, it also checks the live Copilot model catalog. OpenClaw -prefers the documented general-purpose Copilot CLI model when that model is +prefers the provider's current general-purpose model when that model is enabled for the account; otherwise it chooses a deterministic eligible fallback. Setup fails without writing a new auth profile if the account has no picker-visible model that supports streaming and tool calls. An explicitly diff --git a/extensions/github-copilot/index.test.ts b/extensions/github-copilot/index.test.ts index dc7022ec281a..39189054f782 100644 --- a/extensions/github-copilot/index.test.ts +++ b/extensions/github-copilot/index.test.ts @@ -29,7 +29,7 @@ const mocks = vi.hoisted(() => ({ release: vi.fn(async () => {}), })), resolveCopilotRuntimeAuth: vi.fn(), - resolveCopilotStarterModel: vi.fn(async () => "github-copilot/claude-sonnet-4.6"), + resolveCopilotStarterModel: vi.fn(async () => "github-copilot/claude-sonnet-5"), })); function requireAuthMethod(methods: readonly T[], index: number): T { @@ -569,7 +569,7 @@ describe("github-copilot plugin", () => { }, }, ], - defaultModel: "github-copilot/claude-sonnet-4.6", + defaultModel: "github-copilot/claude-sonnet-5", }); expect(mocks.resolveCopilotStarterModel).toHaveBeenCalledWith({ githubToken: "existing-token", @@ -1366,11 +1366,9 @@ describe("github-copilot plugin", () => { mode: "token", }); expect(result?.agents?.defaults?.model).toEqual({ - primary: "github-copilot/claude-sonnet-4.6", + primary: "github-copilot/claude-sonnet-5", }); - expect(result?.agents?.defaults?.models?.["github-copilot/claude-sonnet-4.6"]).toStrictEqual( - {}, - ); + expect(result?.agents?.defaults?.models?.["github-copilot/claude-sonnet-5"]).toStrictEqual({}); const profile = ensureAuthProfileStore(agentDir).profiles["github-copilot:github"]; expect(profile).toEqual({ @@ -1494,7 +1492,7 @@ describe("github-copilot plugin", () => { expect(runtime.error).not.toHaveBeenCalled(); expect(result?.agents?.defaults?.model).toEqual({ fallbacks: ["openai/gpt-5.4"], - primary: "github-copilot/claude-sonnet-4.6", + primary: "github-copilot/claude-sonnet-5", }); const profile = ensureAuthProfileStore(agentDir).profiles["github-copilot:github"]; diff --git a/extensions/github-copilot/model-metadata.ts b/extensions/github-copilot/model-metadata.ts index 09d93560e93c..57a09b700a77 100644 --- a/extensions/github-copilot/model-metadata.ts +++ b/extensions/github-copilot/model-metadata.ts @@ -8,9 +8,9 @@ type CopilotReasoningCompat = { supportedReasoningEfforts?: readonly string[] | null; }; -// GitHub Copilot CLI's documented general-purpose default. Setup treats this -// as a preference only and verifies it against the authenticated live catalog. -export const DEFAULT_COPILOT_MODEL = "github-copilot/claude-sonnet-4.6"; +// Provider-owned general-purpose preference. Setup verifies it against the +// authenticated live catalog instead of assuming every account can use it. +export const DEFAULT_COPILOT_MODEL = "github-copilot/claude-sonnet-5"; const COPILOT_CHAT_COMPLETIONS_COMPAT: ModelDefinitionConfig["compat"] = { supportsStore: false, diff --git a/extensions/github-copilot/models.test.ts b/extensions/github-copilot/models.test.ts index eacdefaa41a9..40cc337b64a7 100644 --- a/extensions/github-copilot/models.test.ts +++ b/extensions/github-copilot/models.test.ts @@ -617,7 +617,7 @@ describe("fetchCopilotModelCatalog", () => { pickerEnabled?: boolean; policyState?: string; preview?: boolean; - streaming?: boolean; + streaming?: boolean | "omit"; toolCalls?: boolean; }) { return { @@ -635,7 +635,7 @@ describe("fetchCopilotModelCatalog", () => { max_output_tokens: params.maxTokens ?? 64_000, }, supports: { - streaming: params.streaming ?? true, + ...(params.streaming === "omit" ? {} : { streaming: params.streaming ?? true }), tool_calls: params.toolCalls ?? true, }, }, @@ -685,6 +685,14 @@ describe("fetchCopilotModelCatalog", () => { expect(selectCopilotStarterModel(models, "hidden")).toBeUndefined(); }); + it("does not treat omitted streaming metadata as an explicit lack of support", async () => { + const models = await fetchSelectionFixture([ + selectableModelEntry({ id: "omitted-streaming", streaming: "omit" }), + ]); + + expect(selectCopilotStarterModel(models, "omitted-streaming")?.id).toBe("omitted-streaming"); + }); + it("maps Copilot /models entries to ModelDefinitionConfig with real context windows", async () => { const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, sampleApiResponse)); diff --git a/extensions/github-copilot/models.ts b/extensions/github-copilot/models.ts index b5591be68d33..315336652870 100644 --- a/extensions/github-copilot/models.ts +++ b/extensions/github-copilot/models.ts @@ -128,7 +128,7 @@ type CopilotModelSelectionMetadata = { pickerEnabled: boolean; policyState?: string; preview: boolean; - streaming: boolean; + streaming?: boolean; toolCalls: boolean; }; @@ -151,7 +151,9 @@ export function isCopilotCatalogModelVisible(model: CopilotCatalogModel): boolea function isCopilotCatalogModelSelectable(model: CopilotCatalogModel): boolean { const metadata = readCopilotModelSelectionMetadata(model); - return Boolean(isCopilotCatalogModelVisible(model) && metadata?.streaming && metadata.toolCalls); + return Boolean( + isCopilotCatalogModelVisible(model) && metadata?.streaming !== false && metadata?.toolCalls, + ); } const COPILOT_STARTER_CATEGORY_RANK = new Map([ @@ -306,7 +308,7 @@ function mapCopilotApiModelToDefinition( pickerEnabled: entry.model_picker_enabled === true, policyState: normalizeOptionalLowercaseString(entry.policy?.state), preview: entry.preview === true, - streaming: supports?.streaming === true, + streaming: supports?.streaming, toolCalls: supports?.tool_calls === true, }); return definition; diff --git a/src/system-agent/setup-inference-plan.ts b/src/system-agent/setup-inference-plan.ts index d5205e014c1b..8746901411d6 100644 --- a/src/system-agent/setup-inference-plan.ts +++ b/src/system-agent/setup-inference-plan.ts @@ -591,9 +591,41 @@ async function runProviderManualSecretMethod(params: { throw new Error(methodError || `Provider setup exited with code ${code}.`); }, }; + const existingPrimary = resolveAgentModelPrimaryValue(params.config.agents?.defaults?.model); + const existingProvider = existingPrimary ? parseRef(existingPrimary).provider : undefined; + let providerSetupConfig = params.config; + if ( + existingProvider && + normalizeProviderId(existingProvider) !== normalizeProviderId(params.choice.providerId) + ) { + const agents = params.config.agents; + const defaults = agents?.defaults; + const model = defaults?.model; + if (defaults && model !== undefined) { + const { model: _model, ...defaultsWithoutModel } = defaults; + let modelWithoutPrimary: Exclude | undefined; + if (typeof model === "object" && model !== null) { + const { primary: _primary, ...remainingModelConfig } = model; + modelWithoutPrimary = remainingModelConfig; + } + // App-guided setup needs this provider's verified candidate, not an + // unrelated current default. The original config remains the merge base. + providerSetupConfig = { + ...params.config, + agents: { + ...agents, + defaults: + modelWithoutPrimary && Object.keys(modelWithoutPrimary).length > 0 + ? { ...defaultsWithoutModel, model: modelWithoutPrimary } + : defaultsWithoutModel, + }, + }; + } + } + const configured = await runNonInteractive({ authChoice: params.choice.choiceId, - config: params.config, + config: providerSetupConfig, baseConfig: params.baseConfig, opts: { [optionKey]: params.apiKey, secretInputMode: "plaintext" }, runtime: isolatedRuntime, diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index 0c7536b92082..253fa6b82305 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -15,6 +15,7 @@ import { type AgentExecutionAuthBinding, } from "../agents/execution-auth-binding.js"; import { detectInferenceBackends } from "../commands/onboard-inference.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { withoutPluginInstallRecords } from "../plugins/installed-plugin-index-records.js"; @@ -3719,16 +3720,19 @@ describe("activateSetupInference", () => { it.each([ { - name: "uses a provider starter model instead of an unrelated existing default", + name: "requests a dynamic provider model instead of using a static starter", existingModel: "openai/gpt-5.2", - starterModel: "github-copilot/claude-sonnet-4.5", + starterModel: "github-copilot/static-should-not-win", + expectedSetupInputModel: undefined, }, { name: "accepts an unchanged provider-owned dynamic model", existingModel: "github-copilot/claude-sonnet-4.5", starterModel: undefined, + expectedSetupInputModel: "github-copilot/claude-sonnet-4.5", }, - ])("$name without starting interactive login", async ({ existingModel, starterModel }) => { + ])("$name without starting interactive login", async (testCase) => { + const { existingModel, starterModel, expectedSetupInputModel } = testCase; const stateDir = await makeTempDir(); const agentDir = path.join(stateDir, "agent"); const runInteractive = vi.fn(); @@ -3838,6 +3842,10 @@ describe("activateSetupInference", () => { opts: expect.objectContaining({ githubCopilotToken: "github-token" }), }), ); + const setupInputConfig = runNonInteractive.mock.calls[0]?.[0].config; + expect(resolveAgentModelPrimaryValue(setupInputConfig?.agents?.defaults?.model)).toBe( + expectedSetupInputModel, + ); const activatedProfileId = runEmbeddedAgent.mock.calls[0]?.[0].authProfileId; if (!activatedProfileId) { throw new Error("expected setup auth profile");