Files
openclaw/extensions/github-copilot/models.ts
Rain 110eb787a1 fix(github-copilot): preserve catalog thinking efforts in requests (#107834)
* fix(github-copilot): preserve catalog thinking efforts in requests

Unify discovered and bundled capability mapping with the provider thinking policy. Preserve supported xhigh/max Responses efforts and map minimal to the supported low minimum, while respecting explicit account opt-outs and transport limits.

Fixes #107792

Co-authored-by: Pluviobyte <Pluviobyte@users.noreply.github.com>

* fix(github-copilot): resolve nullable thinking policy transport

Accept the public policy API context and resolve missing transports before enforcing Claude and Gemini effort restrictions. Cover undefined and null API values without changing explicit Responses routes.

* refactor(github-copilot): normalize manifest models as one catalog

Use the canonical batch model provider builder after the single-row helper was removed on main. Preserve model transport and compatibility decoration without a legacy API shim.

* refactor(github-copilot): decorate owned catalog rows in place

Keep the normalized manifest batch as the sole owner of runtime rows and apply transport metadata directly, avoiding redundant row copies.

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Pluviobyte <Pluviobyte@users.noreply.github.com>
2026-08-26 21:09:21 -07:00

383 lines
13 KiB
TypeScript

// Github Copilot plugin module implements models behavior.
import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
import { buildCopilotIdeHeaders } from "openclaw/plugin-sdk/provider-auth";
import { readProviderJsonArrayFieldResponse } from "openclaw/plugin-sdk/provider-http";
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
import { normalizeModelCompat } from "openclaw/plugin-sdk/provider-model-shared";
import {
asPositiveSafeInteger,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import {
resolveCopilotModelCompat,
resolveCopilotThinkingLevelMap,
resolveCopilotTransportApi,
resolveStaticCopilotModelOverride,
} from "./model-metadata.js";
import { COPILOT_RUNTIME_INTEGRATION_ID } from "./runtime-identity.js";
export const PROVIDER_ID = "github-copilot";
const DEFAULT_CONTEXT_WINDOW = 128_000;
const DEFAULT_MAX_TOKENS = 8192;
function isCopilotCodexModelId(modelId: string): boolean {
return /(?:^|[-_.])codex(?:$|[-_.])/.test(modelId);
}
export function resolveCopilotForwardCompatModel(
ctx: ProviderResolveDynamicModelContext,
): ProviderRuntimeModel | undefined {
const trimmedModelId = ctx.modelId.trim();
if (!trimmedModelId) {
return undefined;
}
// If the model is already in the registry, let the normal path handle it.
const lowerModelId = normalizeOptionalLowercaseString(trimmedModelId) ?? "";
const existing = ctx.modelRegistry.find(PROVIDER_ID, lowerModelId);
if (existing) {
return undefined;
}
const staticOverride = resolveStaticCopilotModelOverride(lowerModelId);
if (staticOverride) {
const compat = staticOverride.compat ?? resolveCopilotModelCompat(trimmedModelId);
return normalizeModelCompat({
id: trimmedModelId,
name: staticOverride.name ?? trimmedModelId,
provider: PROVIDER_ID,
api: staticOverride.api ?? resolveCopilotTransportApi(trimmedModelId),
reasoning: staticOverride.reasoning ?? false,
input: staticOverride.input ?? ["text", "image"],
cost: staticOverride.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: staticOverride.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
...(staticOverride.contextTokens !== undefined
? { contextTokens: staticOverride.contextTokens }
: {}),
maxTokens: staticOverride.maxTokens ?? DEFAULT_MAX_TOKENS,
...(staticOverride.thinkingLevelMap
? { thinkingLevelMap: staticOverride.thinkingLevelMap }
: {}),
...(compat ? { compat } : {}),
} as ProviderRuntimeModel);
}
// Catch-all: create a synthetic model definition for any unknown model ID.
// The Copilot API is OpenAI-compatible and will return its own error if the
// model isn't available on the user's plan. This lets new models be used
// by simply adding them to agents.defaults.models in openclaw.json — no
// code change required.
const reasoning = /^o[13](\b|$)/.test(lowerModelId) || isCopilotCodexModelId(lowerModelId);
const compat = resolveCopilotModelCompat(trimmedModelId);
return normalizeModelCompat({
id: trimmedModelId,
name: trimmedModelId,
provider: PROVIDER_ID,
api: resolveCopilotTransportApi(trimmedModelId),
reasoning,
// Optimistic: most Copilot models support images, and the API rejects
// image payloads for text-only models rather than failing silently.
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
...(compat ? { compat } : {}),
} as ProviderRuntimeModel);
}
// Subset of the Copilot /models response shape that we depend on. We only read
// fields we need; everything else is preserved as `unknown` so upstream changes
// don't break parsing.
type CopilotApiModelEntry = {
id?: string;
name?: string;
object?: string;
vendor?: string;
preview?: boolean;
model_picker_enabled?: boolean;
model_picker_category?: string;
policy?: {
state?: string;
};
capabilities?: {
type?: string;
family?: string;
limits?: {
max_context_window_tokens?: number;
max_output_tokens?: number;
max_prompt_tokens?: number;
};
supports?: {
vision?: boolean;
tool_calls?: boolean;
streaming?: boolean;
structured_outputs?: boolean;
reasoning_effort?: string[] | null;
};
};
};
type CopilotModelSelectionMetadata = {
category?: string;
pickerEnabled: boolean;
policyState?: string;
preview: boolean;
streaming?: boolean;
toolCalls: boolean;
};
const copilotModelSelectionMetadata = new WeakMap<object, CopilotModelSelectionMetadata>();
function readCopilotModelSelectionMetadata(
model: CopilotCatalogModel,
): CopilotModelSelectionMetadata | undefined {
return copilotModelSelectionMetadata.get(model);
}
export function isCopilotCatalogModelVisible(model: CopilotCatalogModel): boolean {
const metadata = readCopilotModelSelectionMetadata(model);
return Boolean(
metadata?.pickerEnabled &&
metadata.policyState !== "disabled" &&
metadata.policyState !== "unconfigured",
);
}
function isCopilotCatalogModelSelectable(model: CopilotCatalogModel): boolean {
const metadata = readCopilotModelSelectionMetadata(model);
return Boolean(
isCopilotCatalogModelVisible(model) && metadata?.streaming !== false && metadata?.toolCalls,
);
}
const COPILOT_STARTER_CATEGORY_RANK = new Map<string, number>([
["versatile", 0],
["lightweight", 1],
["powerful", 2],
]);
function compareCopilotStarterCandidates(
left: CopilotCatalogModel,
right: CopilotCatalogModel,
): number {
const leftMetadata = readCopilotModelSelectionMetadata(left);
const rightMetadata = readCopilotModelSelectionMetadata(right);
const previewDelta =
Number(leftMetadata?.preview === true) - Number(rightMetadata?.preview === true);
if (previewDelta !== 0) {
return previewDelta;
}
const categoryDelta =
(COPILOT_STARTER_CATEGORY_RANK.get(leftMetadata?.category ?? "") ?? Number.MAX_SAFE_INTEGER) -
(COPILOT_STARTER_CATEGORY_RANK.get(rightMetadata?.category ?? "") ?? Number.MAX_SAFE_INTEGER);
if (categoryDelta !== 0) {
return categoryDelta;
}
const contextDelta =
(right.contextWindow ?? DEFAULT_CONTEXT_WINDOW) -
(left.contextWindow ?? DEFAULT_CONTEXT_WINDOW);
if (contextDelta !== 0) {
return contextDelta;
}
const outputDelta = right.maxTokens - left.maxTokens;
if (outputDelta !== 0) {
return outputDelta;
}
return left.id.localeCompare(right.id);
}
export function selectCopilotStarterModel(
models: readonly CopilotCatalogModel[],
preferredModelId: string,
): CopilotCatalogModel | undefined {
const selectable = models.filter(isCopilotCatalogModelSelectable);
return (
selectable.find((model) => model.id === preferredModelId) ??
selectable.toSorted(compareCopilotStarterCandidates)[0]
);
}
export const COPILOT_MODELS_LIST_DEFAULT_TIMEOUT_MS = 10_000;
const COPILOT_ROUTER_ID_PREFIX = "accounts/";
type CopilotCatalogModel = Omit<ModelDefinitionConfig, "input"> & {
api: NonNullable<ModelDefinitionConfig["api"]>;
input: ProviderRuntimeModel["input"];
};
function resolveCopilotApiForVendor(
vendor: string | undefined,
modelId: string,
): "anthropic-messages" | "openai-completions" | "openai-responses" {
if (vendor && vendor.toLowerCase() === "anthropic") {
return "anthropic-messages";
}
return resolveCopilotTransportApi(modelId);
}
function mergeCopilotCompat(
base: ModelDefinitionConfig["compat"] | undefined,
reasoningEfforts: string[] | null | undefined,
): ModelDefinitionConfig["compat"] | undefined {
const supportedReasoningEfforts = Array.isArray(reasoningEfforts)
? [
...new Set(
reasoningEfforts
.map((effort) => normalizeOptionalLowercaseString(effort))
.filter((effort): effort is string => Boolean(effort)),
),
]
: [];
if (!Array.isArray(reasoningEfforts)) {
return base;
}
return {
...base,
supportedReasoningEfforts,
};
}
function mapCopilotApiModelToDefinition(
entry: CopilotApiModelEntry,
): CopilotCatalogModel | undefined {
const id = entry.id?.trim();
if (!id) {
return undefined;
}
// Skip non-chat objects (embeddings, routers, etc.) and internal router ids.
if (entry.object && entry.object !== "model") {
return undefined;
}
if (entry.capabilities?.type && entry.capabilities.type !== "chat") {
return undefined;
}
if (id.startsWith(COPILOT_ROUTER_ID_PREFIX)) {
return undefined;
}
const limits = entry.capabilities?.limits;
const supports = entry.capabilities?.supports;
const reasoning = Array.isArray(supports?.reasoning_effort)
? supports.reasoning_effort.length > 0
: false;
const supportsVision = supports?.vision === true;
const input: CopilotCatalogModel["input"] = supportsVision ? ["text", "image"] : ["text"];
const contextWindow =
asPositiveSafeInteger(limits?.max_context_window_tokens) ?? DEFAULT_CONTEXT_WINDOW;
const contextTokens = asPositiveSafeInteger(limits?.max_prompt_tokens);
const maxTokens = asPositiveSafeInteger(limits?.max_output_tokens) ?? DEFAULT_MAX_TOKENS;
const compat = mergeCopilotCompat(resolveCopilotModelCompat(id), supports?.reasoning_effort);
const api = resolveCopilotApiForVendor(entry.vendor, id);
const thinkingLevelMap = resolveCopilotThinkingLevelMap(id, compat, api);
const definition: CopilotCatalogModel = {
id,
name: entry.name?.trim() || id,
api,
reasoning,
input,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow,
...(contextTokens !== undefined ? { contextTokens } : {}),
maxTokens,
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
...(compat ? { compat } : {}),
};
copilotModelSelectionMetadata.set(definition, {
category: normalizeOptionalLowercaseString(entry.model_picker_category),
pickerEnabled: entry.model_picker_enabled === true,
policyState: normalizeOptionalLowercaseString(entry.policy?.state),
preview: entry.preview === true,
streaming: supports?.streaming,
toolCalls: supports?.tool_calls === true,
});
return definition;
}
function asCopilotApiModelEntry(value: unknown): CopilotApiModelEntry {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error("Copilot /models: malformed JSON response");
}
return value as CopilotApiModelEntry;
}
type FetchCopilotModelCatalogParams = {
/** GitHub source token accepted by the account's Copilot API endpoint. */
copilotApiToken: string;
/** Resolved baseUrl from the same token-exchange response. */
baseUrl: string;
/** Optional fetch override for testing. */
fetchImpl?: typeof fetch;
/** Optional AbortSignal; defaults to a 10s timeout. */
signal?: AbortSignal;
};
/**
* Fetch the live Copilot model catalog from `${baseUrl}/models` and project it
* into `ModelDefinitionConfig[]`. Used by the plugin's discovery hook so the
* runtime catalog tracks per-account entitlements + accurate context windows
* without manifest churn.
*
* Filters out non-chat objects (embeddings, routers) and internal router ids.
* On any HTTP/parse failure the caller should fall back to the static manifest
* catalog; this function throws so the caller decides the recovery shape.
*/
export async function fetchCopilotModelCatalog(
params: FetchCopilotModelCatalogParams,
): Promise<CopilotCatalogModel[]> {
const fetchImpl = params.fetchImpl ?? fetch;
const trimmedBase = params.baseUrl.replace(/\/+$/, "");
if (!trimmedBase) {
throw new Error("fetchCopilotModelCatalog: baseUrl required");
}
if (!params.copilotApiToken.trim()) {
throw new Error("fetchCopilotModelCatalog: copilotApiToken required");
}
const url = `${trimmedBase}/models`;
const controller = params.signal ? undefined : new AbortController();
const timeoutId = controller
? setTimeout(() => controller.abort(), COPILOT_MODELS_LIST_DEFAULT_TIMEOUT_MS)
: undefined;
try {
const res = await fetchImpl(url, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${params.copilotApiToken}`,
...buildCopilotIdeHeaders(),
"Copilot-Integration-Id": COPILOT_RUNTIME_INTEGRATION_ID,
},
signal: params.signal ?? controller?.signal,
});
if (!res.ok) {
// Static catalog fallback never consumes this body, so release the transport before cleanup.
await res.body?.cancel().catch(() => undefined);
throw new Error(`Copilot /models fetch failed: HTTP ${res.status}`);
}
const data = await readProviderJsonArrayFieldResponse(res, "Copilot /models", "data");
const seen = new Set<string>();
const out: CopilotCatalogModel[] = [];
for (const rawEntry of data) {
const entry = asCopilotApiModelEntry(rawEntry);
const def = mapCopilotApiModelToDefinition(entry);
if (!def) {
continue;
}
if (seen.has(def.id)) {
continue;
}
seen.add(def.id);
out.push(def);
}
return out;
} finally {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
}
}