Skip to content

Commit 57f5f28

Browse files
committed
feat: enhance GitHub Copilot integration with support for model metadata and endpoint resolution
1 parent cd65186 commit 57f5f28

5 files changed

Lines changed: 607 additions & 30 deletions

File tree

src/llm/copilot-account.ts

Lines changed: 275 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
normalizeToolChoiceForCapability,
1717
type YagrModelCapabilityProfile,
1818
} from './model-capabilities.js';
19+
import { getCachedProviderModelMetadata, primeProviderModelMetadata } from './provider-metadata.js';
1920

2021
export const DEFAULT_COPILOT_API_BASE_URL = 'https://api.individual.githubcopilot.com';
2122
export const GITHUB_COPILOT_DEFAULT_MODEL = 'gpt-4.1';
@@ -286,20 +287,53 @@ async function runGitHubCopilotCompletion(
286287
const regularMode = options.mode.type === 'regular' ? options.mode : undefined;
287288
const tools = getFunctionTools(options.mode, capabilityProfile);
288289
const warnings = buildCopilotWarnings(options, tools);
290+
const endpoint = await resolveCopilotEndpoint(modelId, runtimeAuth.token, runtimeAuth.baseUrl);
291+
const execution = endpoint === '/responses'
292+
? await runCopilotResponsesCompletion(modelId, options.prompt, tools, regularMode?.toolChoice, capabilityProfile, runtimeAuth)
293+
: await runCopilotChatCompletions(modelId, options.prompt, tools, regularMode?.toolChoice, capabilityProfile, runtimeAuth);
294+
return {
295+
...execution,
296+
warnings,
297+
};
298+
}
299+
300+
async function resolveCopilotEndpoint(modelId: string, token: string, baseUrl: string): Promise<'/chat/completions' | '/responses'> {
301+
const cached = getCachedProviderModelMetadata('copilot-proxy', modelId);
302+
const cachedEndpoints = cached?.supportedEndpoints ?? [];
303+
if (cachedEndpoints.includes('/responses') && !cachedEndpoints.includes('/chat/completions')) {
304+
return '/responses';
305+
}
306+
307+
await primeProviderModelMetadata('copilot-proxy', modelId, token, baseUrl).catch(() => undefined);
308+
const metadata = getCachedProviderModelMetadata('copilot-proxy', modelId);
309+
const supportedEndpoints = metadata?.supportedEndpoints ?? [];
310+
if (supportedEndpoints.includes('/responses') && !supportedEndpoints.includes('/chat/completions')) {
311+
return '/responses';
312+
}
313+
314+
return '/chat/completions';
315+
}
316+
317+
async function runCopilotChatCompletions(
318+
modelId: string,
319+
prompt: LanguageModelV1Prompt,
320+
tools: LanguageModelV1FunctionTool[],
321+
toolChoice: LanguageModelV1ToolChoice | undefined,
322+
capabilityProfile: YagrModelCapabilityProfile | undefined,
323+
runtimeAuth: { token: string; baseUrl: string },
324+
): Promise<{
325+
text: string;
326+
finishReason: 'stop' | 'error' | 'tool-calls' | 'length' | 'content-filter' | 'other' | 'unknown';
327+
usage: { promptTokens: number; completionTokens: number };
328+
toolCalls?: LanguageModelV1FunctionToolCall[];
329+
}> {
289330
const response = await fetch(`${runtimeAuth.baseUrl}/chat/completions`, {
290331
method: 'POST',
291-
headers: {
292-
Authorization: `Bearer ${runtimeAuth.token}`,
293-
'Content-Type': 'application/json',
294-
Accept: 'application/json',
295-
'User-Agent': COPILOT_USER_AGENT,
296-
'Editor-Version': COPILOT_EDITOR_VERSION,
297-
'Editor-Plugin-Version': COPILOT_EDITOR_PLUGIN_VERSION,
298-
},
332+
headers: buildCopilotHeaders(runtimeAuth.token),
299333
body: JSON.stringify({
300334
model: modelId,
301-
messages: toOpenAiMessages(options.prompt),
302-
...(tools.length > 0 ? { tools: toOpenAiTools(tools), tool_choice: toOpenAiToolChoice(regularMode?.toolChoice, capabilityProfile) } : {}),
335+
messages: toOpenAiMessages(prompt),
336+
...(tools.length > 0 ? { tools: toOpenAiTools(tools), tool_choice: toOpenAiToolChoice(toolChoice, capabilityProfile) } : {}),
303337
stream: false,
304338
}),
305339
});
@@ -311,13 +345,65 @@ async function runGitHubCopilotCompletion(
311345

312346
const payload = await response.json() as Record<string, unknown>;
313347
const toolCalls = extractOpenAiToolCalls(payload);
314-
const finishReason = normalizeFinishReason(payload, toolCalls);
348+
const finishReason = normalizeChatCompletionsFinishReason(payload, toolCalls);
315349
return {
316-
text: extractCopilotText(payload),
350+
text: extractChatCompletionsText(payload),
317351
finishReason,
318-
usage: extractOpenAiUsage(payload),
352+
usage: extractChatCompletionsUsage(payload),
319353
...(toolCalls.length > 0 ? { toolCalls } : {}),
320-
warnings,
354+
};
355+
}
356+
357+
async function runCopilotResponsesCompletion(
358+
modelId: string,
359+
prompt: LanguageModelV1Prompt,
360+
tools: LanguageModelV1FunctionTool[],
361+
toolChoice: LanguageModelV1ToolChoice | undefined,
362+
capabilityProfile: YagrModelCapabilityProfile | undefined,
363+
runtimeAuth: { token: string; baseUrl: string },
364+
): Promise<{
365+
text: string;
366+
finishReason: 'stop' | 'error' | 'tool-calls' | 'length' | 'content-filter' | 'other' | 'unknown';
367+
usage: { promptTokens: number; completionTokens: number };
368+
toolCalls?: LanguageModelV1FunctionToolCall[];
369+
}> {
370+
const { instructions, input } = convertPromptToResponsesInput(prompt);
371+
const response = await fetch(`${runtimeAuth.baseUrl}/responses`, {
372+
method: 'POST',
373+
headers: buildCopilotHeaders(runtimeAuth.token),
374+
body: JSON.stringify({
375+
model: modelId,
376+
...(instructions ? { instructions } : {}),
377+
input,
378+
...(tools.length > 0 ? { tools: toResponsesTools(tools), tool_choice: toResponsesToolChoice(toolChoice, capabilityProfile) } : {}),
379+
stream: false,
380+
}),
381+
});
382+
383+
if (!response.ok) {
384+
const body = await response.text();
385+
throw new Error(body.trim() || `GitHub Copilot responses completion failed: HTTP ${response.status}`);
386+
}
387+
388+
const payload = await response.json() as Record<string, unknown>;
389+
const toolCalls = extractResponsesToolCalls(payload);
390+
const finishReason = normalizeResponsesFinishReason(payload, toolCalls);
391+
return {
392+
text: extractResponsesText(payload),
393+
finishReason,
394+
usage: extractResponsesUsage(payload),
395+
...(toolCalls.length > 0 ? { toolCalls } : {}),
396+
};
397+
}
398+
399+
function buildCopilotHeaders(token: string): Record<string, string> {
400+
return {
401+
Authorization: `Bearer ${token}`,
402+
'Content-Type': 'application/json',
403+
Accept: 'application/json',
404+
'User-Agent': COPILOT_USER_AGENT,
405+
'Editor-Version': COPILOT_EDITOR_VERSION,
406+
'Editor-Plugin-Version': COPILOT_EDITOR_PLUGIN_VERSION,
321407
};
322408
}
323409

@@ -711,7 +797,90 @@ function toOpenAiMessages(prompt: LanguageModelV1Prompt): Array<Record<string, u
711797
}).flat();
712798
}
713799

714-
function extractCopilotText(payload: Record<string, unknown>): string {
800+
function convertPromptToResponsesInput(prompt: LanguageModelV1Prompt): {
801+
instructions: string | undefined;
802+
input: Array<Record<string, unknown>>;
803+
} {
804+
let instructions: string | undefined;
805+
const input: Array<Record<string, unknown>> = [];
806+
807+
for (const message of prompt) {
808+
if (message.role === 'system') {
809+
instructions = message.content;
810+
continue;
811+
}
812+
if (message.role === 'user') {
813+
const text = message.content.map((p) => p.type === 'text' ? p.text : `[${p.type}]`).join('\n');
814+
input.push({ role: 'user', content: [{ type: 'input_text', text }] });
815+
} else if (message.role === 'assistant') {
816+
const text = message.content
817+
.filter((p) => p.type === 'text' || p.type === 'reasoning')
818+
.map((p) => p.text)
819+
.join('\n')
820+
.trim();
821+
if (text) {
822+
input.push({ role: 'assistant', content: [{ type: 'output_text', text }] });
823+
}
824+
825+
for (const part of message.content) {
826+
if (part.type !== 'tool-call') {
827+
continue;
828+
}
829+
input.push({
830+
type: 'function_call',
831+
call_id: part.toolCallId,
832+
name: part.toolName,
833+
arguments: JSON.stringify(part.args ?? {}),
834+
});
835+
}
836+
} else {
837+
for (const part of message.content) {
838+
input.push({
839+
type: 'function_call_output',
840+
call_id: part.toolCallId,
841+
output: stringifyToolResult(part.result),
842+
});
843+
}
844+
}
845+
}
846+
847+
return { instructions, input };
848+
}
849+
850+
function toResponsesTools(tools: LanguageModelV1FunctionTool[]): Array<Record<string, unknown>> {
851+
return tools.map((tool) => ({
852+
type: 'function',
853+
name: tool.name,
854+
...(tool.description ? { description: tool.description } : {}),
855+
parameters: tool.parameters,
856+
strict: true,
857+
}));
858+
}
859+
860+
function toResponsesToolChoice(
861+
toolChoice: LanguageModelV1ToolChoice | undefined,
862+
capabilityProfile?: YagrModelCapabilityProfile,
863+
): unknown {
864+
const normalizedToolChoice = capabilityProfile
865+
? normalizeToolChoiceForCapability(toolChoice, capabilityProfile)
866+
: toolChoice;
867+
868+
if (!normalizedToolChoice || normalizedToolChoice.type === 'auto') {
869+
return 'auto';
870+
}
871+
if (normalizedToolChoice.type === 'none' || normalizedToolChoice.type === 'required') {
872+
return normalizedToolChoice.type;
873+
}
874+
if (normalizedToolChoice.type === 'tool') {
875+
return {
876+
type: 'function',
877+
name: normalizedToolChoice.toolName,
878+
};
879+
}
880+
return 'auto';
881+
}
882+
883+
function extractChatCompletionsText(payload: Record<string, unknown>): string {
715884
const choices = payload.choices;
716885
if (!Array.isArray(choices) || choices.length === 0) {
717886
return '';
@@ -731,14 +900,49 @@ function extractCopilotText(payload: Record<string, unknown>): string {
731900
return '';
732901
}
733902

734-
function extractOpenAiUsage(payload: Record<string, unknown>): { promptTokens: number; completionTokens: number } {
903+
function extractResponsesText(payload: Record<string, unknown>): string {
904+
const outputText = readOptionalString(payload.output_text);
905+
if (outputText) {
906+
return outputText;
907+
}
908+
909+
const output = Array.isArray(payload.output) ? payload.output : [];
910+
return output.flatMap((entry) => {
911+
if (!entry || typeof entry !== 'object') {
912+
return [];
913+
}
914+
915+
const record = entry as Record<string, unknown>;
916+
if (record.type === 'message' && Array.isArray(record.content)) {
917+
return record.content.flatMap((part) => {
918+
if (!part || typeof part !== 'object') {
919+
return [];
920+
}
921+
const partRecord = part as Record<string, unknown>;
922+
return typeof partRecord.text === 'string' ? [partRecord.text] : [];
923+
});
924+
}
925+
926+
return [];
927+
}).join('');
928+
}
929+
930+
function extractChatCompletionsUsage(payload: Record<string, unknown>): { promptTokens: number; completionTokens: number } {
735931
const usage = payload.usage as Record<string, unknown> | undefined;
736932
return {
737933
promptTokens: typeof usage?.prompt_tokens === 'number' ? usage.prompt_tokens : 0,
738934
completionTokens: typeof usage?.completion_tokens === 'number' ? usage.completion_tokens : 0,
739935
};
740936
}
741937

938+
function extractResponsesUsage(payload: Record<string, unknown>): { promptTokens: number; completionTokens: number } {
939+
const usage = payload.usage as Record<string, unknown> | undefined;
940+
return {
941+
promptTokens: typeof usage?.input_tokens === 'number' ? usage.input_tokens : 0,
942+
completionTokens: typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0,
943+
};
944+
}
945+
742946
function extractOpenAiToolCalls(payload: Record<string, unknown>): LanguageModelV1FunctionToolCall[] {
743947
const choices = payload.choices;
744948
if (!Array.isArray(choices) || choices.length === 0) {
@@ -775,7 +979,37 @@ function extractOpenAiToolCalls(payload: Record<string, unknown>): LanguageModel
775979
.filter((entry): entry is LanguageModelV1FunctionToolCall => Boolean(entry));
776980
}
777981

778-
function normalizeFinishReason(
982+
function extractResponsesToolCalls(payload: Record<string, unknown>): LanguageModelV1FunctionToolCall[] {
983+
const output = Array.isArray(payload.output) ? payload.output : [];
984+
return output
985+
.map((entry, index) => {
986+
if (!entry || typeof entry !== 'object') {
987+
return undefined;
988+
}
989+
990+
const call = entry as Record<string, unknown>;
991+
if (call.type !== 'function_call') {
992+
return undefined;
993+
}
994+
995+
const id = readOptionalString(call.call_id) || readOptionalString(call.id) || `copilot-responses-tool-call-${index + 1}`;
996+
const toolName = readOptionalString(call.name);
997+
const args = readOptionalString(call.arguments);
998+
if (!toolName || !args) {
999+
return undefined;
1000+
}
1001+
1002+
return {
1003+
toolCallType: 'function' as const,
1004+
toolCallId: id,
1005+
toolName,
1006+
args,
1007+
};
1008+
})
1009+
.filter((entry): entry is LanguageModelV1FunctionToolCall => Boolean(entry));
1010+
}
1011+
1012+
function normalizeChatCompletionsFinishReason(
7791013
payload: Record<string, unknown>,
7801014
toolCalls: LanguageModelV1FunctionToolCall[],
7811015
): 'stop' | 'error' | 'tool-calls' | 'length' | 'content-filter' | 'other' | 'unknown' {
@@ -803,6 +1037,30 @@ function normalizeFinishReason(
8031037
return 'other';
8041038
}
8051039

1040+
function normalizeResponsesFinishReason(
1041+
payload: Record<string, unknown>,
1042+
toolCalls: LanguageModelV1FunctionToolCall[],
1043+
): 'stop' | 'error' | 'tool-calls' | 'length' | 'content-filter' | 'other' | 'unknown' {
1044+
if (toolCalls.length > 0) {
1045+
return 'tool-calls';
1046+
}
1047+
1048+
const raw = readOptionalString(payload.status);
1049+
if (!raw) {
1050+
return 'unknown';
1051+
}
1052+
if (raw === 'completed') {
1053+
return 'stop';
1054+
}
1055+
if (raw === 'incomplete') {
1056+
return 'length';
1057+
}
1058+
if (raw === 'failed') {
1059+
return 'error';
1060+
}
1061+
return 'other';
1062+
}
1063+
8061064
function stringifyToolResult(value: unknown): string {
8071065
if (typeof value === 'string') {
8081066
return value;

0 commit comments

Comments
 (0)