Files
OmniRoute/open-sse/executors/github.ts
diegosouzapw 4ae488b25b feat(runtime): add hot-reloadable guardrails and model diagnostics
Introduce a runtime settings layer that hydrates persisted config at startup
and reapplies aliases, payload rules, cache behavior, CLI compatibility,
usage tuning, and related switches when settings change or SQLite updates.

Replace the legacy prompt injection middleware path with a guardrail
registry that supports prompt injection detection, PII masking, disabled
guardrail overrides, and post-call response handling across the chat
pipeline.

Add a metadata registry for model catalog and alias resolution so catalog
endpoints return enriched capabilities plus diagnostic headers and typed
alias errors instead of ad hoc responses.

Convert unsupported built-in web_search tools into an OmniRoute fallback
tool, execute them through builtin skills, and preserve Responses API
function call output with sanitized usage fields.

Centralize provider header fingerprints for GitHub, Cursor, Qwen, Qoder,
Kiro, and Antigravity, and migrate management passwords from env or
plaintext storage into persisted bcrypt hashes during startup and login.
2026-04-17 11:56:52 -03:00

270 lines
9.6 KiB
TypeScript

import { BaseExecutor, ExecuteInput } from "./base.ts";
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
import { getModelTargetFormat } from "../config/providerModels.ts";
import {
getGitHubCopilotChatHeaders,
getGitHubCopilotRefreshHeaders,
} from "../config/providerHeaderProfiles.ts";
export class GithubExecutor extends BaseExecutor {
/** Stashed per-request so buildHeaders() can read the client's x-initiator value. */
private _clientHeaders: Record<string, string> | null = null;
constructor() {
super("github", PROVIDERS.github);
}
getCopilotToken(credentials) {
return credentials?.copilotToken || credentials?.providerSpecificData?.copilotToken || null;
}
getCopilotTokenExpiresAt(credentials) {
return (
credentials?.copilotTokenExpiresAt ||
credentials?.providerSpecificData?.copilotTokenExpiresAt ||
null
);
}
buildUrl(model, stream, urlIndex = 0) {
const targetFormat = getModelTargetFormat("gh", model);
if (targetFormat === "openai-responses") {
return (
this.config.responsesBaseUrl ||
this.config.baseUrl?.replace(/\/chat\/completions\/?$/, "/responses") ||
"https://api.githubcopilot.com/responses"
);
}
return this.config.baseUrl;
}
injectResponseFormat(messages: any[], responseFormat: any) {
if (!responseFormat) return messages;
let formatInstruction = "";
if (responseFormat.type === "json_object") {
formatInstruction =
"Respond only with valid JSON. Do not include any text before or after the JSON object.";
} else if (responseFormat.type === "json_schema" && responseFormat.json_schema) {
formatInstruction = `Respond only with valid JSON matching this schema:\n${JSON.stringify(
responseFormat.json_schema.schema,
null,
2
)}\nDo not include any text before or after the JSON.`;
}
if (!formatInstruction) return messages;
const systemIdx = messages.findIndex((m: any) => m.role === "system");
if (systemIdx >= 0) {
return messages.map((m: any, i: number) =>
i === systemIdx ? { ...m, content: `${m.content}\n\n${formatInstruction}` } : m
);
}
return [{ role: "system", content: formatInstruction }, ...messages];
}
transformRequest(model: string, body: any, stream: boolean, credentials: any): any {
const modifiedBody = JSON.parse(JSON.stringify(body));
if (modifiedBody.response_format && model.toLowerCase().includes("claude")) {
modifiedBody.messages = this.injectResponseFormat(
modifiedBody.messages,
modifiedBody.response_format
);
delete modifiedBody.response_format;
}
// Strip reasoning_text / reasoning_content from assistant messages.
// GitHub Copilot converts these into Anthropic thinking blocks but cannot
// supply a valid `signature`, causing upstream 400 errors.
if (Array.isArray(modifiedBody.messages)) {
for (const msg of modifiedBody.messages) {
if (msg.role === "assistant") {
delete msg.reasoning_text;
delete msg.reasoning_content;
}
}
}
return modifiedBody;
}
async execute(input: ExecuteInput) {
this._clientHeaders = input.clientHeaders ?? null;
try {
const result = await super.execute(input);
if (!result || !result.response) return result;
if (!input.stream) {
// wreq-js clone/text semantics consume the original response body. Materialize
// non-streaming responses immediately so downstream code always sees a native
// fetch Response with a readable body.
const status = result.response.status;
const statusText = result.response.statusText;
const headers = new Headers(result.response.headers);
const payload = await result.response.text();
result.response = new Response(payload, { status, statusText, headers });
return result;
}
if (!result.response.body) return result;
const isStreaming = input.stream === true;
const contentType = (result.response.headers.get("content-type") || "").toLowerCase();
if (isStreaming && result.response.ok && contentType.includes("text/event-stream")) {
// Preserve the original response body for downstream error handling.
const sourceResponse = result.response.clone();
if (!sourceResponse.body) return result;
const decoder = new TextDecoder();
const transformStream = new TransformStream({
transform(chunk, controller) {
const text = decoder.decode(chunk, { stream: true });
if (text.includes("data: [DONE]")) {
return;
}
controller.enqueue(chunk);
},
});
const newResponse = new Response(sourceResponse.body.pipeThrough(transformStream), {
status: sourceResponse.status,
statusText: sourceResponse.statusText,
headers: new Headers(sourceResponse.headers),
});
result.response = newResponse;
}
return result;
} finally {
this._clientHeaders = null;
}
}
buildHeaders(credentials, stream = true) {
const token = this.getCopilotToken(credentials) || credentials.accessToken;
// Forward the client's x-initiator header when present. OpenCode and other
// Copilot-aware clients use this to distinguish user-initiated turns
// (x-initiator: user) from autonomous tool-call continuations
// (x-initiator: agent). GitHub Copilot's billing treats "agent" turns as
// free, so forwarding the value avoids burning a premium request on every
// tool-call round-trip. Fall back to "user" when the header is absent to
// preserve the existing default behaviour.
const ch = this._clientHeaders;
const clientInitiator = ch?.["x-initiator"] || ch?.["X-Initiator"];
const initiator =
clientInitiator === "agent" || clientInitiator === "user" ? clientInitiator : "user";
return {
...getGitHubCopilotChatHeaders(stream ? "text/event-stream" : "application/json", initiator),
Authorization: `Bearer ${token}`,
"x-request-id":
crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`,
};
}
async refreshCopilotToken(githubAccessToken, log) {
try {
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
headers: getGitHubCopilotRefreshHeaders(`token ${githubAccessToken}`),
});
if (!response.ok) return null;
const data = await response.json();
log?.info?.("TOKEN", "Copilot token refreshed");
return { token: data.token, expiresAt: data.expires_at };
} catch (error) {
log?.error?.("TOKEN", `Copilot refresh error: ${error.message}`);
return null;
}
}
async refreshGitHubToken(refreshToken, log) {
try {
const response = await fetch(OAUTH_ENDPOINTS.github.token, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
}),
});
if (!response.ok) return null;
const tokens = await response.json();
log?.info?.("TOKEN", "GitHub token refreshed");
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || refreshToken,
expiresIn: tokens.expires_in,
};
} catch (error) {
log?.error?.("TOKEN", `GitHub refresh error: ${error.message}`);
return null;
}
}
async refreshCredentials(credentials, log) {
let copilotResult = await this.refreshCopilotToken(credentials.accessToken, log);
if (!copilotResult && credentials.refreshToken) {
const githubTokens = await this.refreshGitHubToken(credentials.refreshToken, log);
if (githubTokens?.accessToken) {
copilotResult = await this.refreshCopilotToken(githubTokens.accessToken, log);
if (copilotResult) {
return {
...githubTokens,
copilotToken: copilotResult.token,
copilotTokenExpiresAt: copilotResult.expiresAt,
providerSpecificData: {
copilotToken: copilotResult.token,
copilotTokenExpiresAt: copilotResult.expiresAt,
},
};
}
return githubTokens;
}
}
if (copilotResult) {
return {
accessToken: credentials.accessToken,
refreshToken: credentials.refreshToken,
copilotToken: copilotResult.token,
copilotTokenExpiresAt: copilotResult.expiresAt,
providerSpecificData: {
copilotToken: copilotResult.token,
copilotTokenExpiresAt: copilotResult.expiresAt,
},
};
}
return null;
}
needsRefresh(credentials) {
// Always refresh if no copilotToken
if (!this.getCopilotToken(credentials)) return true;
const copilotTokenExpiresAt = this.getCopilotTokenExpiresAt(credentials);
if (copilotTokenExpiresAt) {
// Handle both Unix timestamp (seconds) and ISO string
let expiresAtMs = copilotTokenExpiresAt;
if (typeof expiresAtMs === "number" && expiresAtMs < 1e12) {
expiresAtMs = expiresAtMs * 1000; // Convert seconds to ms
} else if (typeof expiresAtMs === "string") {
expiresAtMs = new Date(expiresAtMs).getTime();
}
if (expiresAtMs - Date.now() < 5 * 60 * 1000) return true;
}
return super.needsRefresh(credentials);
}
}
export default GithubExecutor;