fix: use /responses for GitHub Copilot codex models

Route Copilot codex models away from /chat/completions to avoid 400
errors, and keep ping/model verification aligned with runtime routing.
This commit is contained in:
Keryer 2026-03-21 22:22:40 +08:00
parent 23fb3b42ef
commit c96a9d87e0
6 changed files with 213 additions and 80 deletions

View file

@ -23,6 +23,7 @@ import {
ModelInfo,
safeFetch,
safeFetchNoThrow,
shouldUseGitHubCopilotResponsesApi,
} from "@/utils";
import { HarmBlockThreshold, HarmCategory } from "@google/generative-ai";
import { ChatAnthropic } from "@langchain/anthropic";
@ -42,6 +43,7 @@ import { ChatOpenRouter } from "./ChatOpenRouter";
import { ChatLMStudio } from "./ChatLMStudio";
import { BedrockChatModel, type BedrockChatModelFields } from "./BedrockChatModel";
import { GitHubCopilotChatModel } from "@/LLMProviders/githubCopilot/GitHubCopilotChatModel";
import { GitHubCopilotResponsesModel } from "@/LLMProviders/githubCopilot/GitHubCopilotResponsesModel";
// Patch BaseLanguageModel.prototype.getNumTokens once at module load to prevent
// tiktoken CDN fetches. LangChain's default getNumTokens() downloads a ~3MB BPE
@ -821,6 +823,11 @@ export default class ChatModelManager {
logInfo(`Enabling Responses API for GPT-5 model: ${model.name} (${selectedModel.vendor})`);
}
if (shouldUseGitHubCopilotResponsesApi(model)) {
constructorConfig.useResponsesApi = true;
logInfo(`Enabling Responses API for GitHub Copilot model: ${model.name}`);
}
// For LM Studio, use ChatLMStudio by default for Responses API compatibility.
// Opt out by setting useResponsesApi to false.
if (model.provider === ChatModelProviders.LM_STUDIO && model.useResponsesApi !== false) {
@ -829,6 +836,10 @@ export default class ChatModelManager {
return lmStudioInstance;
}
if (shouldUseGitHubCopilotResponsesApi(model)) {
return new GitHubCopilotResponsesModel(constructorConfig);
}
const newModelInstance = new selectedModel.AIConstructor(constructorConfig);
return newModelInstance;
@ -898,12 +909,18 @@ export default class ChatModelManager {
constructorConfig.useResponsesApi = true;
}
if (shouldUseGitHubCopilotResponsesApi(model)) {
constructorConfig.useResponsesApi = true;
}
// For LM Studio with Responses API, ping via ChatLMStudio so the
// connectivity check hits the same /v1/responses endpoint used in chats.
const testModel =
model.provider === ChatModelProviders.LM_STUDIO && model.useResponsesApi !== false
? new ChatLMStudio(constructorConfig)
: new (this.getProviderConstructor(modelToTest))(constructorConfig);
: shouldUseGitHubCopilotResponsesApi(model)
? new GitHubCopilotResponsesModel(constructorConfig)
: new (this.getProviderConstructor(modelToTest))(constructorConfig);
await testModel.invoke([{ role: "user", content: "hello" }], {
timeout: 8000,
});
@ -914,7 +931,7 @@ export default class ChatModelManager {
await tryPing(false);
return true;
} catch (firstError) {
console.log("First ping attempt failed, trying with CORS...");
logInfo("First ping attempt failed, retrying with CORS enabled.");
try {
// Second try with CORS
await tryPing(true);

View file

@ -1,6 +1,7 @@
import type { BaseMessageChunk, MessageContent } from "@langchain/core/messages";
import { ChatOpenAICompletions } from "@langchain/openai";
import { COPILOT_API_BASE, GitHubCopilotProvider } from "./GitHubCopilotProvider";
import { buildGitHubCopilotAuthedFetch } from "./GitHubCopilotResponsesModel";
import type { FetchImplementation } from "@/utils";
import { extractTextFromChunk } from "@/utils";
@ -16,6 +17,8 @@ const CHARS_PER_TOKEN = 4;
* ChatOpenAICompletions' `_streamResponseChunks` skips chunks with non-string
* content, causing all text to be silently dropped. This normalizer ensures
* content is always a string before it reaches that check.
* @param content - Raw delta content from the transport layer.
* @returns Normalized plain-text content.
*/
function normalizeDeltaContent(content: unknown): string {
if (typeof content === "string") return content;
@ -38,7 +41,9 @@ function normalizeDeltaContent(content: unknown): string {
}
/** Extract the constructor fields type from ChatOpenAICompletions. */
type ChatOpenAICompletionsFields = NonNullable<ConstructorParameters<typeof ChatOpenAICompletions>[0]>;
type ChatOpenAICompletionsFields = NonNullable<
ConstructorParameters<typeof ChatOpenAICompletions>[0]
>;
/**
* Constructor params for GitHubCopilotChatModel.
@ -53,14 +58,8 @@ export type GitHubCopilotChatModelParams = ChatOpenAICompletionsFields & {
/**
* GitHub Copilot ChatModel built on top of ChatOpenAICompletions.
*
* Reason: We extend ChatOpenAICompletions instead of ChatOpenAI because:
* 1. ChatOpenAI routes between Completions API and Responses API internally.
* GitHub Copilot only supports the Chat Completions API endpoint.
* 2. ChatOpenAICompletions provides `bindTools()` (via BaseChatOpenAI),
* `_streamResponseChunks`, and `_convertCompletionsDeltaToBaseMessageChunk`
* directly no routing indirection.
* 3. `_convertCompletionsDeltaToBaseMessageChunk` is directly overridable,
* allowing us to normalize non-string content from Claude models.
* This class is kept for Copilot models that still speak the Chat Completions API.
* Codex models are routed separately through GitHubCopilotResponsesModel.
*
* Authentication (dynamic Copilot token refresh) and Copilot-specific headers
* are injected via `configuration.fetch` using GitHubCopilotProvider's lifecycle.
@ -72,67 +71,21 @@ export class GitHubCopilotChatModel extends ChatOpenAICompletions {
/**
* Build a fetch wrapper that injects a valid Copilot token and custom headers
* on every request, with automatic 401 retry after token refresh.
*
* @param provider - GitHubCopilotProvider singleton for token management
* @param baseFetch - Underlying fetch implementation (native or CORS-safe)
* @returns A fetch-compatible function with Copilot auth injected
* @param provider - GitHubCopilotProvider singleton for token management.
* @param baseFetch - Underlying fetch implementation (native or CORS-safe).
* @returns A fetch-compatible function with Copilot auth injected.
*/
private static buildAuthedFetch(
provider: GitHubCopilotProvider,
baseFetch: FetchImplementation
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
return async (input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> => {
// Reason: OpenAI SDK v6 always calls fetch(urlString, init), so we only need
// to handle string and URL inputs. Request objects are not used by the SDK,
// but we extract the URL defensively to avoid silent failures.
// Note: If a future SDK version passes Request objects, this wrapper would
// need to clone the Request to preserve method/body/headers and support retry.
// Reason: Guard `typeof Request` to avoid ReferenceError in environments
// where the Request global may not exist (e.g., some Obsidian mobile runtimes).
const url =
typeof input === "string"
? input
: typeof Request !== "undefined" && input instanceof Request
? input.url
: input.toString();
const doRequest = async (token: string): Promise<Response> => {
const copilotHeaders = provider.buildCopilotRequestHeaders(token);
const mergedHeaders = new Headers(init.headers);
// Copilot headers take precedence (especially Authorization)
for (const [key, value] of Object.entries(copilotHeaders)) {
mergedHeaders.set(key, value);
}
return baseFetch(url, { ...init, headers: mergedHeaders });
};
let token = await provider.getValidCopilotToken();
let response = await doRequest(token);
// 401: invalidate cached token and retry once with a fresh one.
// Reason: Only retry on 401 (Unauthorized / expired token). 403 means
// "Forbidden" (e.g., no Copilot subscription) — a permanent condition
// where token refresh won't help. This matches GitHubCopilotProvider's
// own retry logic which also limits retries to 401.
if (response.status === 401) {
try {
await response.body?.cancel();
} catch {
// Ignore cancellation errors — body may already be closed
}
provider.invalidateCopilotToken();
token = await provider.getValidCopilotToken();
response = await doRequest(token);
}
return response;
};
return buildGitHubCopilotAuthedFetch(provider, baseFetch);
}
/**
* Create a Copilot-backed ChatOpenAICompletions instance.
* Wires up dynamic token refresh and Copilot headers via a custom fetch wrapper.
* @param fields - LangChain/OpenAI constructor fields with Copilot fetch options.
*/
constructor(fields: GitHubCopilotChatModelParams) {
const { fetchImplementation, configuration, apiKey, ...rest } = fields;
@ -143,15 +96,10 @@ export class GitHubCopilotChatModel extends ChatOpenAICompletions {
super({
...rest,
// ChatOpenAICompletions requires an apiKey but Copilot tokens are dynamic;
// real Authorization is injected in the fetch wrapper above.
apiKey: apiKey || "copilot-dynamic-token",
// Reason: Copilot API may not support stream_options.include_usage,
// which ChatOpenAI sends by default. Disable to avoid potential 400 errors.
streamUsage: false,
configuration: {
...(configuration ?? {}),
// Reason: OpenAI SDK appends "/chat/completions" to baseURL automatically
baseURL: (configuration?.baseURL as string) ?? COPILOT_API_BASE,
fetch: authedFetch,
},
@ -175,27 +123,20 @@ export class GitHubCopilotChatModel extends ChatOpenAICompletions {
* 2. Non-string content: Claude models may return delta.content as an array of
* content parts. The parent's _streamResponseChunks skips chunks where
* `typeof content !== "string"`, silently dropping all text.
* @param delta - Streaming delta payload.
* @param rawResponse - Raw transport response chunk.
* @param defaultRole - Fallback role inferred by LangChain.
* @returns A normalized LangChain message chunk.
*/
protected override _convertCompletionsDeltaToBaseMessageChunk(
delta: Record<string, any>,
rawResponse: any,
// Reason: Parent expects OpenAI's ChatCompletionRole type, but we accept any string
// to avoid coupling to the exact OpenAI SDK type. Cast is safe because we pass through.
defaultRole?: any
): BaseMessageChunk {
// Reason: Copilot API omits delta.role when proxying Claude models.
// The parent converter uses `delta.role ?? defaultRole` to determine message type.
// If both are undefined, it falls through to ChatMessageChunk (no tool_call_chunks).
// Model responses are always from the assistant role, so defaulting here is safe.
// We set defaultRole instead of mutating delta.role to avoid modifying transport objects.
if (!delta.role && !defaultRole) {
defaultRole = "assistant";
}
// Reason: Mutate delta.content in place instead of spreading.
// OpenAI SDK's delta objects may have non-enumerable properties (e.g., tool_calls)
// that would be lost by `{ ...delta }` spread. Direct mutation is safe because
// each delta is a single-use streaming chunk.
delta.content = normalizeDeltaContent(delta.content);
return super._convertCompletionsDeltaToBaseMessageChunk(delta, rawResponse, defaultRole);
@ -204,6 +145,8 @@ export class GitHubCopilotChatModel extends ChatOpenAICompletions {
/**
* Simple token estimation based on character count.
* Kept as a safe fallback for direct usage outside ChatModelManager.
* @param content - Message content to estimate.
* @returns Approximate token count.
*/
override async getNumTokens(content: MessageContent): Promise<number> {
const text = extractTextFromChunk(content);

View file

@ -68,8 +68,8 @@ export interface CopilotAuthState {
* - Token management (access token + copilot token)
* - Model listing
*
* Chat completions are handled by GitHubCopilotChatModel (extends ChatOpenAICompletions),
* which uses this provider for token lifecycle via buildCopilotRequestHeaders/getValidCopilotToken.
* Chat requests are handled by GitHubCopilotChatModel / GitHubCopilotResponsesModel,
* which use this provider for token lifecycle via buildCopilotRequestHeaders/getValidCopilotToken.
*
* WARNING: This uses GitHub Copilot's internal API which is not officially
* supported for third-party applications. Use at your own risk.

View file

@ -0,0 +1,100 @@
import { ChatOpenAI } from "@langchain/openai";
import { COPILOT_API_BASE, GitHubCopilotProvider } from "./GitHubCopilotProvider";
import type { FetchImplementation } from "@/utils";
/** Extract the constructor fields type from ChatOpenAI. */
type ChatOpenAIFields = NonNullable<ConstructorParameters<typeof ChatOpenAI>[0]>;
/**
* Constructor params for GitHubCopilotResponsesModel.
* Inherits all ChatOpenAI fields and adds Copilot-specific fetch injection.
*/
export type GitHubCopilotResponsesModelParams = ChatOpenAIFields & {
/** Custom fetch implementation for CORS bypass (e.g., safeFetchNoThrow on mobile) */
fetchImplementation?: FetchImplementation;
};
/**
* Builds a fetch wrapper that injects a valid Copilot token and custom headers
* on every request, with automatic 401 retry after token refresh.
* @param provider - GitHubCopilotProvider singleton for token management.
* @param baseFetch - Underlying fetch implementation (native or CORS-safe).
* @returns A fetch-compatible function with Copilot auth injected.
*/
export function buildGitHubCopilotAuthedFetch(
provider: GitHubCopilotProvider,
baseFetch: FetchImplementation
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
return async (input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> => {
const url =
typeof input === "string"
? input
: typeof Request !== "undefined" && input instanceof Request
? input.url
: input.toString();
const doRequest = async (token: string): Promise<Response> => {
const copilotHeaders = provider.buildCopilotRequestHeaders(token);
const mergedHeaders = new Headers(init.headers);
for (const [key, value] of Object.entries(copilotHeaders)) {
mergedHeaders.set(key, value);
}
return baseFetch(url, { ...init, headers: mergedHeaders });
};
let token = await provider.getValidCopilotToken();
let response = await doRequest(token);
if (response.status === 401) {
try {
await response.body?.cancel();
} catch {
// Ignore cancellation errors when the body is already closed.
}
provider.invalidateCopilotToken();
token = await provider.getValidCopilotToken();
response = await doRequest(token);
}
return response;
};
}
/**
* GitHub Copilot model that routes requests through the Responses API.
* Used for Copilot Codex models, which reject the Chat Completions endpoint.
*/
export class GitHubCopilotResponsesModel extends ChatOpenAI {
/**
* Create a Copilot-backed ChatOpenAI instance configured for `/responses`.
* Wires up dynamic token refresh and Copilot headers via a custom fetch wrapper.
* @param fields - LangChain/OpenAI constructor fields with Copilot fetch options.
*/
constructor(fields: GitHubCopilotResponsesModelParams) {
const { fetchImplementation, configuration, apiKey, ...rest } = fields;
const provider = GitHubCopilotProvider.getInstance();
const baseFetch = fetchImplementation ?? (configuration?.fetch as FetchImplementation) ?? fetch;
const authedFetch = buildGitHubCopilotAuthedFetch(provider, baseFetch);
super({
...rest,
apiKey: apiKey || "copilot-dynamic-token",
useResponsesApi: true,
streamUsage: false,
configuration: {
...(configuration ?? {}),
baseURL: (configuration?.baseURL as string) ?? COPILOT_API_BASE,
fetch: authedFetch,
},
});
}
/** LangChain model type identifier. */
override _llmType(): string {
return "github-copilot";
}
}

View file

@ -7,6 +7,7 @@ import {
getNotesFromTags,
getUtf8ByteLength,
isFolderMatch,
shouldUseGitHubCopilotResponsesApi,
processVariableNameForNotePath,
removeThinkTags,
stripFrontmatter,
@ -14,6 +15,7 @@ import {
withTimeout,
} from "./utils";
import { TimeoutError } from "./error";
import { ChatModelProviders } from "./constants";
// Mock Obsidian's TFile class
jest.mock("obsidian", () => {
@ -527,6 +529,45 @@ I need to consider:
});
});
describe("shouldUseGitHubCopilotResponsesApi", () => {
it("should enable responses API for GitHub Copilot codex models", () => {
expect(
shouldUseGitHubCopilotResponsesApi({
provider: ChatModelProviders.GITHUB_COPILOT,
name: "gpt-5.3-codex",
})
).toBe(true);
});
it("should not enable responses API for non-codex GitHub Copilot models by default", () => {
expect(
shouldUseGitHubCopilotResponsesApi({
provider: ChatModelProviders.GITHUB_COPILOT,
name: "gpt-4.1",
})
).toBe(false);
});
it("should ignore codex names for non-Copilot providers", () => {
expect(
shouldUseGitHubCopilotResponsesApi({
provider: ChatModelProviders.OPENAI,
name: "gpt-5.3-codex",
})
).toBe(false);
});
it("should allow explicit responses API opt-in for GitHub Copilot models", () => {
expect(
shouldUseGitHubCopilotResponsesApi({
provider: ChatModelProviders.GITHUB_COPILOT,
name: "custom-model",
useResponsesApi: true,
})
).toBe(true);
});
});
describe("withTimeout", () => {
it("should return result when operation completes within timeout", async () => {
const operation = async (signal: AbortSignal) => {

View file

@ -1180,6 +1180,38 @@ export function isGPT5Model(model: BaseChatModel | string): boolean {
return modelName.startsWith("gpt-5");
}
/**
* Checks whether a model belongs to the Codex family.
* Codex model identifiers consistently include the "codex" token.
* @param model - Model instance or model name string.
* @returns True when the model name indicates a Codex model.
*/
export function isCodexModel(model: BaseChatModel | string): boolean {
const modelName =
typeof model === "string" ? model : (model as any).modelName || (model as any).model || "";
return modelName.toLowerCase().includes("codex");
}
/**
* Determines whether a GitHub Copilot model should use the Responses API.
* Copilot Codex models reject `/chat/completions` and must be sent to `/responses`.
* @param model - Minimal model configuration used for routing.
* @returns True when the model should be routed to `/responses`.
*/
export function shouldUseGitHubCopilotResponsesApi(
model: Pick<CustomModel, "provider" | "name" | "useResponsesApi">
): boolean {
if (model.provider !== ChatModelProviders.GITHUB_COPILOT) {
return false;
}
if (model.useResponsesApi === true) {
return true;
}
return isCodexModel(model.name);
}
/**
* Utility for determining model characteristics
* Note: Most of this is handled by LangChain 0.6.6+ internally