mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
23fb3b42ef
commit
c96a9d87e0
6 changed files with 213 additions and 80 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
100
src/LLMProviders/githubCopilot/GitHubCopilotResponsesModel.ts
Normal file
100
src/LLMProviders/githubCopilot/GitHubCopilotResponsesModel.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
32
src/utils.ts
32
src/utils.ts
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue