Skip to content

Commit 5137a51

Browse files
feat(github-copilot): resolve any model ID dynamically (#51325)
* feat(github-copilot): resolve any model ID dynamically instead of only known ones * refactor(github-copilot): extract model resolution, add reasoning heuristic and tests * fix(github-copilot): default synthetic models to text-only input * ci: retrigger checks * copilot: mark synthetic catch-all models as vision-capable * fix(github-copilot): anchor reasoning regex, unexport internal constants, add mid-string test * fix(github-copilot): default synthetic models to text-only input * fix(github-copilot): restore image input for synthetic models with explanatory comment * fix(github-copilot): normalize registry lookup casing, add bare o3 test case * fix: preserve configured overrides for dynamic models * fix: allow dynamic GitHub Copilot models (#51325) (thanks @fuller-stack-dev) --------- Co-authored-by: Ayaan Zaidi <hi@obviy.us>
1 parent 42e708d commit 5137a51

6 files changed

Lines changed: 223 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Docs: https://docs.openclaw.ai
5656
- Plugins/context engines: add transcript maintenance rewrites for context engines, preserve active-branch transcript metadata during rewrites, and harden overflow-recovery truncation to rewrite sessions under the normal session write lock. (#51191) Thanks @jalehman.
5757
- Telegram/apiRoot: add per-account custom Bot API endpoint support across send, probe, setup, doctor repair, and inbound media download paths so proxied or self-hosted Telegram deployments work end to end. (#48842) Thanks @Cypherm.
5858
- Telegram/topics: auto-rename DM forum topics on first message with LLM-generated labels, with per-account and per-DM `autoTopicLabel` overrides. (#51502) Thanks @Lukavyi.
59+
- Models/GitHub Copilot: allow forward-compat dynamic model ids without code updates, while preserving configured provider and per-model overrides for those synthetic models. (#51325) Thanks @fuller-stack-dev.
5960

6061
### Fixes
6162

extensions/github-copilot/index.ts

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,12 @@
11
import { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/agent-runtime";
2-
import {
3-
definePluginEntry,
4-
type ProviderAuthContext,
5-
type ProviderResolveDynamicModelContext,
6-
type ProviderRuntimeModel,
7-
} from "openclaw/plugin-sdk/core";
2+
import { definePluginEntry, type ProviderAuthContext } from "openclaw/plugin-sdk/core";
83
import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth";
94
import { githubCopilotLoginCommand } from "openclaw/plugin-sdk/provider-auth-login";
10-
import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models";
5+
import { PROVIDER_ID, resolveCopilotForwardCompatModel } from "./models.js";
116
import { DEFAULT_COPILOT_API_BASE_URL, resolveCopilotApiToken } from "./token.js";
127
import { fetchCopilotUsage } from "./usage.js";
138

14-
const PROVIDER_ID = "github-copilot";
159
const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
16-
const CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex";
17-
const CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const;
1810
const COPILOT_XHIGH_MODEL_IDS = ["gpt-5.2", "gpt-5.2-codex"] as const;
1911

2012
function resolveFirstGithubToken(params: { agentDir?: string; env: NodeJS.ProcessEnv }): {
@@ -51,27 +43,6 @@ function resolveFirstGithubToken(params: { agentDir?: string; env: NodeJS.Proces
5143
return { githubToken: "", hasProfile };
5244
}
5345

54-
function resolveCopilotForwardCompatModel(
55-
ctx: ProviderResolveDynamicModelContext,
56-
): ProviderRuntimeModel | undefined {
57-
const trimmedModelId = ctx.modelId.trim();
58-
if (trimmedModelId.toLowerCase() !== CODEX_GPT_53_MODEL_ID) {
59-
return undefined;
60-
}
61-
for (const templateId of CODEX_TEMPLATE_MODEL_IDS) {
62-
const template = ctx.modelRegistry.find(PROVIDER_ID, templateId) as ProviderRuntimeModel | null;
63-
if (!template) {
64-
continue;
65-
}
66-
return normalizeModelCompat({
67-
...template,
68-
id: trimmedModelId,
69-
name: trimmedModelId,
70-
} as ProviderRuntimeModel);
71-
}
72-
return undefined;
73-
}
74-
7546
async function runGitHubCopilotAuth(ctx: ProviderAuthContext) {
7647
await ctx.prompter.note(
7748
[
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("@mariozechner/pi-ai/oauth", () => ({
4+
getOAuthApiKey: vi.fn(),
5+
getOAuthProviders: vi.fn(() => []),
6+
}));
7+
8+
vi.mock("openclaw/plugin-sdk/provider-models", () => ({
9+
normalizeModelCompat: (model: Record<string, unknown>) => model,
10+
}));
11+
12+
import type { ProviderResolveDynamicModelContext } from "openclaw/plugin-sdk/core";
13+
import { resolveCopilotForwardCompatModel } from "./models.js";
14+
15+
function createMockCtx(
16+
modelId: string,
17+
registryModels: Record<string, Record<string, unknown>> = {},
18+
): ProviderResolveDynamicModelContext {
19+
return {
20+
modelId,
21+
provider: "github-copilot",
22+
config: {},
23+
modelRegistry: {
24+
find: (provider: string, id: string) => registryModels[`${provider}/${id}`] ?? null,
25+
},
26+
} as unknown as ProviderResolveDynamicModelContext;
27+
}
28+
29+
describe("resolveCopilotForwardCompatModel", () => {
30+
it("returns undefined for empty modelId", () => {
31+
expect(resolveCopilotForwardCompatModel(createMockCtx(""))).toBeUndefined();
32+
expect(resolveCopilotForwardCompatModel(createMockCtx(" "))).toBeUndefined();
33+
});
34+
35+
it("returns undefined when model is already in registry", () => {
36+
const ctx = createMockCtx("gpt-4o", {
37+
"github-copilot/gpt-4o": { id: "gpt-4o", name: "gpt-4o" },
38+
});
39+
expect(resolveCopilotForwardCompatModel(ctx)).toBeUndefined();
40+
});
41+
42+
it("clones gpt-5.2-codex template for gpt-5.3-codex", () => {
43+
const template = {
44+
id: "gpt-5.2-codex",
45+
name: "gpt-5.2-codex",
46+
provider: "github-copilot",
47+
api: "openai-responses",
48+
reasoning: true,
49+
contextWindow: 200_000,
50+
};
51+
const ctx = createMockCtx("gpt-5.3-codex", {
52+
"github-copilot/gpt-5.2-codex": template,
53+
});
54+
const result = resolveCopilotForwardCompatModel(ctx);
55+
expect(result).toBeDefined();
56+
expect(result!.id).toBe("gpt-5.3-codex");
57+
expect(result!.name).toBe("gpt-5.3-codex");
58+
expect((result as unknown as Record<string, unknown>).reasoning).toBe(true);
59+
});
60+
61+
it("falls through to synthetic catch-all when codex template is missing", () => {
62+
const ctx = createMockCtx("gpt-5.3-codex");
63+
const result = resolveCopilotForwardCompatModel(ctx);
64+
expect(result).toBeDefined();
65+
expect(result!.id).toBe("gpt-5.3-codex");
66+
});
67+
68+
it("creates synthetic model for arbitrary unknown model ID", () => {
69+
const ctx = createMockCtx("gpt-5.4-mini");
70+
const result = resolveCopilotForwardCompatModel(ctx);
71+
expect(result).toBeDefined();
72+
expect(result!.id).toBe("gpt-5.4-mini");
73+
expect(result!.name).toBe("gpt-5.4-mini");
74+
expect((result as unknown as Record<string, unknown>).api).toBe("openai-responses");
75+
expect((result as unknown as Record<string, unknown>).input).toEqual(["text", "image"]);
76+
});
77+
78+
it("infers reasoning=true for o1/o3 model IDs", () => {
79+
for (const id of ["o1", "o3", "o3-mini", "o1-preview"]) {
80+
const ctx = createMockCtx(id);
81+
const result = resolveCopilotForwardCompatModel(ctx);
82+
expect(result).toBeDefined();
83+
expect((result as unknown as Record<string, unknown>).reasoning).toBe(true);
84+
}
85+
});
86+
87+
it("sets reasoning=false for non-reasoning model IDs including mid-string o1/o3", () => {
88+
for (const id of [
89+
"gpt-5.4-mini",
90+
"claude-sonnet-4.6",
91+
"gpt-4o",
92+
"audio-o1-hd",
93+
"turbo-o3-voice",
94+
]) {
95+
const ctx = createMockCtx(id);
96+
const result = resolveCopilotForwardCompatModel(ctx);
97+
expect(result).toBeDefined();
98+
expect((result as unknown as Record<string, unknown>).reasoning).toBe(false);
99+
}
100+
});
101+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type {
2+
ProviderResolveDynamicModelContext,
3+
ProviderRuntimeModel,
4+
} from "openclaw/plugin-sdk/core";
5+
import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-models";
6+
7+
export const PROVIDER_ID = "github-copilot";
8+
const CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex";
9+
const CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const;
10+
11+
const DEFAULT_CONTEXT_WINDOW = 128_000;
12+
const DEFAULT_MAX_TOKENS = 8192;
13+
14+
export function resolveCopilotForwardCompatModel(
15+
ctx: ProviderResolveDynamicModelContext,
16+
): ProviderRuntimeModel | undefined {
17+
const trimmedModelId = ctx.modelId.trim();
18+
if (!trimmedModelId) {
19+
return undefined;
20+
}
21+
22+
// If the model is already in the registry, let the normal path handle it.
23+
const existing = ctx.modelRegistry.find(PROVIDER_ID, trimmedModelId.toLowerCase());
24+
if (existing) {
25+
return undefined;
26+
}
27+
28+
// For gpt-5.3-codex specifically, clone from the gpt-5.2-codex template
29+
// to preserve any special settings the registry has for codex models.
30+
if (trimmedModelId.toLowerCase() === CODEX_GPT_53_MODEL_ID) {
31+
for (const templateId of CODEX_TEMPLATE_MODEL_IDS) {
32+
const template = ctx.modelRegistry.find(
33+
PROVIDER_ID,
34+
templateId,
35+
) as ProviderRuntimeModel | null;
36+
if (!template) {
37+
continue;
38+
}
39+
return normalizeModelCompat({
40+
...template,
41+
id: trimmedModelId,
42+
name: trimmedModelId,
43+
} as ProviderRuntimeModel);
44+
}
45+
// Template not found — fall through to synthetic catch-all below.
46+
}
47+
48+
// Catch-all: create a synthetic model definition for any unknown model ID.
49+
// The Copilot API is OpenAI-compatible and will return its own error if the
50+
// model isn't available on the user's plan. This lets new models be used
51+
// by simply adding them to agents.defaults.models in openclaw.json — no
52+
// code change required.
53+
const lowerModelId = trimmedModelId.toLowerCase();
54+
const reasoning = /^o[13](\b|$)/.test(lowerModelId);
55+
return normalizeModelCompat({
56+
id: trimmedModelId,
57+
name: trimmedModelId,
58+
provider: PROVIDER_ID,
59+
api: "openai-responses",
60+
reasoning,
61+
// Optimistic: most Copilot models support images, and the API rejects
62+
// image payloads for text-only models rather than failing silently.
63+
input: ["text", "image"],
64+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
65+
contextWindow: DEFAULT_CONTEXT_WINDOW,
66+
maxTokens: DEFAULT_MAX_TOKENS,
67+
} as ProviderRuntimeModel);
68+
}

src/agents/pi-embedded-runner/model.test.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -753,9 +753,53 @@ describe("resolveModel", () => {
753753
api: "openai-responses",
754754
baseUrl: "https://proxy.example.com/v1",
755755
});
756-
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toEqual({
757-
"X-Proxy-Auth": "token-123",
756+
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toMatchObject(
757+
{
758+
"X-Proxy-Auth": "token-123",
759+
},
760+
);
761+
});
762+
763+
it("applies configured overrides to github-copilot dynamic models", () => {
764+
const cfg = {
765+
models: {
766+
providers: {
767+
"github-copilot": {
768+
baseUrl: "https://proxy.example.com/v1",
769+
api: "openai-completions",
770+
headers: { "X-Proxy-Auth": "token-123" },
771+
models: [
772+
{
773+
...makeModel("gpt-5.4-mini"),
774+
reasoning: true,
775+
input: ["text"],
776+
contextWindow: 256000,
777+
maxTokens: 32000,
778+
},
779+
],
780+
},
781+
},
782+
},
783+
} as OpenClawConfig;
784+
785+
const result = resolveModel("github-copilot", "gpt-5.4-mini", "/tmp/agent", cfg);
786+
787+
expect(result.error).toBeUndefined();
788+
expect(result.model).toMatchObject({
789+
provider: "github-copilot",
790+
id: "gpt-5.4-mini",
791+
api: "openai-completions",
792+
baseUrl: "https://proxy.example.com/v1",
793+
reasoning: true,
794+
input: ["text"],
795+
contextWindow: 256000,
796+
maxTokens: 32000,
758797
});
798+
expect((result.model as unknown as { headers?: Record<string, string> }).headers).toMatchObject(
799+
{
800+
"X-Proxy-Auth": "token-123",
801+
},
802+
);
759803
});
760804

761805
it("builds an openai fallback for gpt-5.4 mini from the gpt-5-mini template", () => {

src/agents/pi-embedded-runner/model.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,11 @@ export function resolveModelWithRegistry(params: {
266266
provider,
267267
cfg,
268268
agentDir,
269-
model: pluginDynamicModel,
269+
model: applyConfiguredProviderOverrides({
270+
discoveredModel: pluginDynamicModel as Model<Api>,
271+
providerConfig,
272+
modelId,
273+
}),
270274
});
271275
}
272276

0 commit comments

Comments
 (0)