Skip to content
Open
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
136 changes: 21 additions & 115 deletions open-sse/executors/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../config/appConstants.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js";
import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js";
import { initState, translateRequest, translateResponse } from "../translator/index.js";
import { FORMATS } from "../translator/formats.js";
import { initState } from "../translator/index.js";
import { parseSSELine, formatSSE } from "../utils/streamHelpers.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js";
Expand All @@ -19,17 +18,18 @@ export class GithubExecutor extends BaseExecutor {
this.knownCodexModels = new Set();
}

// Claude models get routed to Copilot's Anthropic-native /v1/messages shim (see
// executeWithMessagesEndpoint below) — the only Copilot endpoint that surfaces
// prompt-cache token counts. gpt/gemini/grok models stay on /chat/completions
// (or /responses). Name-pattern check, not a registry field: Copilot's live model
// catalog (services/copilotModels.js) regularly exposes claude-* variants ahead
// of the static registry (registry/github.js).
// Single source of truth for "is this model served by the Anthropic-native shim":
// the registry hook chatCore also uses to pick the target format, so the body shape
// the executor receives and the endpoint it posts to can never disagree.
isClaudeModel(model) {
return /claude/i.test(model || "");
return this.config.resolveTargetFormat?.(model) === "claude";
}

buildUrl(model, stream, urlIndex = 0) {
// Claude models arrive already Anthropic-native (chatCore translated to the
// "claude" target format) — post them to Copilot's /v1/messages shim, the only
// Copilot endpoint that surfaces prompt-cache token counts.
if (this.isClaudeModel(model)) return this.config.messagesUrl;
return this.config.baseUrl;
}

Expand Down Expand Up @@ -96,6 +96,9 @@ export class GithubExecutor extends BaseExecutor {
}

transformRequest(model, body, stream, credentials) {
// Anthropic-native body — none of the OpenAI-shape fixups below apply.
if (this.isClaudeModel(model)) return body;

const transformed = { ...body };
if (this.requiresMaxCompletionTokens(model) && transformed.max_tokens !== undefined) {
transformed.max_completion_tokens = transformed.max_tokens;
Expand Down Expand Up @@ -123,25 +126,23 @@ export class GithubExecutor extends BaseExecutor {
async execute(options) {
const { model, log } = options;

// Claude models: route to Copilot's Anthropic-native /v1/messages shim — the only
// Copilot endpoint that surfaces prompt-cache token counts for Claude. Detected by
// model NAME (not a registry field): Copilot's live model catalog regularly exposes
// claude-* variants the static registry hasn't caught up with yet (see registry/github.js).
if (this.isClaudeModel(model)) {
log?.debug("GITHUB", `Using /v1/messages route for ${model}`);
return this.executeWithMessagesEndpoint(options);
}
// Claude models fall straight through to BaseExecutor.execute(): the body is
// already Anthropic-native and buildUrl() targets /v1/messages, so there is
// nothing left to translate here (chatCore handles both directions).
const isClaude = this.isClaudeModel(model);
if (isClaude) log?.debug("GITHUB", `Using /v1/messages route for ${model}`);

// Only use /responses for models that are explicitly known to need it (e.g. gpt codex models)
// and that the /responses endpoint actually serves (excludes Gemini/Claude, see #1062).
if (this.knownCodexModels.has(model) && this.supportsResponsesEndpoint(model)) {
if (!isClaude && this.knownCodexModels.has(model) && this.supportsResponsesEndpoint(model)) {
log?.debug("GITHUB", `Using cached /responses route for ${model}`);
return this.executeWithResponsesEndpoint(options);
}

// Sanitize messages before sending to /chat/completions (gpt/gemini/grok — the
// endpoint rejects non-text/image_url content parts).
const sanitizedOptions = {
// endpoint rejects non-text/image_url content parts). Claude bodies are Anthropic
// shape and must not be touched.
const sanitizedOptions = isClaude ? options : {
...options,
body: this.sanitizeMessagesForChatCompletions(options.body)
};
Expand Down Expand Up @@ -245,101 +246,6 @@ export class GithubExecutor extends BaseExecutor {
};
}

// Claude models arrive here OpenAI-shape (chatCore.js targets "openai" for github —
// see the note in execute() above), so we translate to Anthropic-native ourselves.
// This is what makes prepareClaudeRequest() (translator/formats/claude.js) inject
// cache_control — /chat/completions never gets there, so it never sees cache tokens.
async executeWithMessagesEndpoint({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
const url = this.config.messagesUrl;
const headers = this.buildHeaders(credentials, stream);

// Force stream:true upstream regardless of client preference, same as
// executeWithResponsesEndpoint below — chatCore.js's non-streaming handler already
// knows how to buffer an SSE response into a single JSON reply when the client
// asked for stream:false.
const transformedBody = translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, model, body, true, credentials, "github");
// _toolNameMap is internal bookkeeping (see openai-to-claude.js) — chatCore.js
// normally strips it before dispatch and threads it into the response state to
// restore original tool names; we must do the same here, or Anthropic's strict
// schema rejects the extra field with a 400.
const toolNameMap = transformedBody._toolNameMap;
delete transformedBody._toolNameMap;

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

const response = await proxyAwareFetch(url, {
method: "POST",
headers,
body: JSON.stringify(transformedBody),
signal
}, proxyOptions);

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

const state = initState(FORMATS.CLAUDE);
state.model = model;
if (toolNameMap) state.toolNameMap = toolNameMap;

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

const emitAll = (controller, chunks) => {
for (const c of chunks) {
controller.enqueue(new TextEncoder().encode(formatSSE(c, "openai")));
}
};

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 && stream === true) {
controller.enqueue(new TextEncoder().encode(SSE_DONE));
continue;
}

emitAll(controller, translateResponse(FORMATS.CLAUDE, FORMATS.OPENAI, parsed, state));
}
},
flush(controller) {
if (buffer.trim()) {
const parsed = parseSSELine(buffer.trim());
if (parsed && !parsed.done) {
emitAll(controller, translateResponse(FORMATS.CLAUDE, FORMATS.OPENAI, parsed, state));
}
}
}
});

if (!response.body) {
return { response: new Response("", { status: response.status, headers: response.headers }), url, headers, transformedBody };
}
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, proxyOptions = null) {
try {
const response = await proxyAwareFetch("https://api.github.com/copilot_internal/v2/token", {
Expand Down
7 changes: 5 additions & 2 deletions open-sse/handlers/chatCore.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { detectFormat, getTargetFormat, resolveTransport } from "../services/provider.js";
import { detectFormat, getTargetFormat, resolveTransport, resolveDynamicTargetFormat } from "../services/provider.js";
import { translateRequest } from "../translator/index.js";
import { stripThinkingSuffix } from "../translator/concerns/thinkingUnified.js";
import { FORMATS } from "../translator/formats.js";
Expand Down Expand Up @@ -58,7 +58,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
if (bypassResponse) return bypassResponse;

const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
const modelTargetFormat = getModelTargetFormat(alias, model);
// Static per-model targetFormat first (explicit registry entries win), then the
// provider's runtime name-pattern hook for catalogs that outrun the static list.
const modelTargetFormat = getModelTargetFormat(alias, model)
|| resolveDynamicTargetFormat(provider, model);
// Multi-endpoint providers: pick transport matching sourceFormat → zero translation
const runtimeTransport = resolveTransport(provider, sourceFormat);
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider);
Expand Down
17 changes: 9 additions & 8 deletions open-sse/providers/registry/github.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export default {
userAgent: "GitHubCopilotChat/0.38.0",
apiVersion: "2025-04-01",
},
// Copilot serves Claude models on its Anthropic-native /v1/messages shim (the only
// endpoint that surfaces prompt-cache token counts) and everything else on
// /chat/completions. Decided by model NAME at request time rather than a static
// per-entry targetFormat in models[] below: Copilot's live catalog
// (services/copilotModels.js) ships claude-* variants long before the static list
// catches up — it currently serves claude-sonnet-5, claude-opus-4.8,
// claude-opus-4.8-fast and claude-fable-5, none of which are listed here.
resolveTargetFormat: (model) => (/claude/i.test(model || "") ? "claude" : null),
usage: {
url: "https://api.github.com/copilot_internal/user",
},
Expand All @@ -47,14 +55,7 @@ export default {
{ id: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
{ id: "gpt-5.4", name: "GPT-5.4" },
{ id: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
// Note: routing to Copilot's Anthropic-native /v1/messages shim (see
// executors/github.js) is decided by model-NAME pattern at request time, not by
// a static targetFormat field here — Copilot's live model catalog (see
// services/copilotModels.js) regularly exposes claude-* models this static list
// hasn't caught up with yet (e.g. claude-opus-4.8), and a static per-entry
// targetFormat would silently miss those while also double-translating requests
// for models that ARE listed here (chatCore.js would pre-translate to Claude
// shape, then the executor would translate again). Keep these as plain entries.
// Claude entries stay plain — transport.resolveTargetFormat above routes them.
{ id: "claude-haiku-4.5", name: "Claude Haiku 4.5" },
{ id: "claude-opus-4.5", name: "Claude Opus 4.5" },
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
Expand Down
18 changes: 18 additions & 0 deletions open-sse/services/provider.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,24 @@ export function resolveTransport(provider, sourceFormat) {
return transports.find(t => t.format === sourceFormat) || null;
}

// Per-model target format resolved at request time from the model NAME.
// For providers that serve different wire formats per model AND whose live model
// catalog outruns their static models[] list, a static per-entry `targetFormat`
// silently misses the newcomers (github/Copilot: claude-* go to the Anthropic-native
// /v1/messages shim, and the catalog ships claude-* variants well before the registry
// lists them). Providers opt in by exporting transport.resolveTargetFormat(model).
// Returns null when the provider has no hook or the model isn't special-cased.
export function resolveDynamicTargetFormat(provider, model) {
const fn = PROVIDERS[provider]?.resolveTargetFormat;
if (typeof fn !== "function") return null;
try {
return fn(model) || null;
} catch {
// Registry data must never be able to break routing.
return null;
}
}

// Check if last message is from user
export function isLastMessageFromUser(body) {
const messages = body.messages || body.contents;
Expand Down
Loading