fix(github-copilot): honor live model selection in guided setup

This commit is contained in:
joshavant
2026-07-30 19:32:31 -05:00
committed by Josh Avant
parent 94542eb872
commit 23dbee9e38
7 changed files with 70 additions and 22 deletions
+3 -3
View File
@@ -31,7 +31,7 @@ provider or agent runtime in three different ways.
</Step>
<Step title="Set a default model">
```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
+5 -7
View File
@@ -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<T>(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"];
+3 -3
View File
@@ -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,
+10 -2
View File
@@ -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));
+5 -3
View File
@@ -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<string, number>([
@@ -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;
+33 -1
View File
@@ -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<typeof model, string> | 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,
+11 -3
View File
@@ -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");