Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions open-sse/config/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export const PROVIDERS = {
},
github: {
baseUrl: "https://api.githubcopilot.com/chat/completions", // GitHub Copilot API endpoint for chat
responsesUrl: "https://api.githubcopilot.com/responses",
format: "openai", // GitHub Copilot uses OpenAI-compatible format
headers: {
"copilot-integration-id": "vscode-chat",
Expand Down
109 changes: 108 additions & 1 deletion open-sse/executors/github.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { BaseExecutor } from "./base.js";
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.js";
import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.js";
import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js";
import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js";
import { initState } from "../translator/index.js";
import { parseSSELine, formatSSE } from "../utils/streamHelpers.js";
import crypto from "crypto";

export class GithubExecutor extends BaseExecutor {
constructor() {
super("github", PROVIDERS.github);
this.knownCodexModels = new Set();
}

buildUrl(model, stream, urlIndex = 0) {
Expand All @@ -28,6 +34,107 @@ export class GithubExecutor extends BaseExecutor {
};
}

async execute(options) {
const { model, log } = options;

if (this.knownCodexModels.has(model)) {
log?.debug("GITHUB", `Using cached /responses route for ${model}`);
return this.executeWithResponsesEndpoint(options);
}

const result = await super.execute(options);

if (result.response.status === HTTP_STATUS.BAD_REQUEST) {
const errorBody = await result.response.clone().text();

if (errorBody.includes("not accessible via the /chat/completions endpoint")) {
log?.warn("GITHUB", `Model ${model} requires /responses. Switching...`);
this.knownCodexModels.add(model);
return this.executeWithResponsesEndpoint(options);
}
Comment on lines +47 to +54

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback trigger checks errorBody.includes("not accessible via the /chat/completions endpoint") on the raw response text. Since GitHub returns structured JSON errors, it would be more robust to JSON.parse and inspect error.message (and guard parse failures). This avoids false positives and makes the fallback resilient to formatting changes (whitespace, wrapping, localization).

Copilot uses AI. Check for mistakes.
}

return result;
}

async executeWithResponsesEndpoint({ model, body, stream, credentials, signal, log }) {
const url = this.config.responsesUrl;
const headers = this.buildHeaders(credentials, stream);

const transformedBody = openaiToOpenAIResponsesRequest(model, body, stream, credentials);

log?.debug("GITHUB", "Sending translated request to /responses");

const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal
Comment on lines +60 to +72

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

executeWithResponsesEndpoint assumes the upstream /responses call returns an SSE stream and immediately pipes response.body through an SSE transformer, but headers/body are still built from the caller-provided stream flag. If stream is false, buildHeaders() sends Accept: application/json, which can cause /responses to return JSON and make the streaming parser/pipeThrough path fail. Either force streaming when using /responses (set Accept to text/event-stream and ensure the request body has stream: true), or add a non-streaming code path that parses JSON and converts it to the expected OpenAI chat completion response format.

Copilot uses AI. Check for mistakes.
});

if (!response.ok) {
return { response, url, headers, transformedBody };
}

const state = initState("openai-responses");
state.model = model;

const decoder = new TextDecoder();
let buffer = "";

const transformStream = new TransformStream({
async transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split("\n");

buffer = lines.pop() || "";

for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;

const parsed = parseSSELine(trimmed);
if (!parsed) continue;

if (parsed.done) {
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
continue;
}

const converted = openaiResponsesToOpenAIResponse(parsed, state);
if (converted) {
const sseString = formatSSE(converted, "openai");
controller.enqueue(new TextEncoder().encode(sseString));
}
Comment on lines +99 to +108

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TransformStream creates a new TextEncoder() for every parsed SSE line (new TextEncoder().encode(...) inside the loop). This adds avoidable overhead in high-token streams. Instantiate a single TextEncoder once (outside the loop / outside the transform callback) and reuse it for all enqueues.

Copilot uses AI. Check for mistakes.
}
},
flush(controller) {
if (buffer.trim()) {
const parsed = parseSSELine(buffer.trim());
if (parsed && !parsed.done) {
const converted = openaiResponsesToOpenAIResponse(parsed, state);
if (converted) {
controller.enqueue(new TextEncoder().encode(formatSSE(converted, "openai")));
}
}
}
Comment on lines +112 to +120

Copilot AI Feb 14, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the SSE transformer flush(), a trailing data: [DONE] line can be dropped if it arrives without a terminating newline: parseSSELine(buffer.trim()) returns {done:true}, but the code only forwards buffered data when !parsed.done. This can leave clients waiting indefinitely for [DONE]. Handle the parsed.done case in flush() (enqueue data: [DONE]\n\n), or flush any remaining buffered line through the same parsed.done branch used in transform().

Suggested change
if (buffer.trim()) {
const parsed = parseSSELine(buffer.trim());
if (parsed && !parsed.done) {
const converted = openaiResponsesToOpenAIResponse(parsed, state);
if (converted) {
controller.enqueue(new TextEncoder().encode(formatSSE(converted, "openai")));
}
}
}
const trimmed = buffer.trim();
if (!trimmed) {
return;
}
const parsed = parseSSELine(trimmed);
if (!parsed) {
return;
}
if (parsed.done) {
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
return;
}
const converted = openaiResponsesToOpenAIResponse(parsed, state);
if (converted) {
controller.enqueue(new TextEncoder().encode(formatSSE(converted, "openai")));
}

Copilot uses AI. Check for mistakes.
}
});

const convertedStream = response.body.pipeThrough(transformStream);

return {
response: new Response(convertedStream, {
status: response.status,
statusText: response.statusText,
headers: response.headers
}),
url,
headers,
transformedBody
};
}

async refreshCopilotToken(githubAccessToken, log) {
try {
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
Expand Down