Merge main into model-registry
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
import type { Agent as HttpAgent } from "node:http";
|
||||
import type { Agent as HttpsAgent } from "node:https";
|
||||
import { HttpProxyAgent } from "http-proxy-agent";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import type { ProviderEnv } from "../types.ts";
|
||||
import { getProviderEnvValue } from "./provider-env.ts";
|
||||
|
||||
const DEFAULT_PROXY_PORTS: Record<string, number> = {
|
||||
ftp: 21,
|
||||
@@ -12,16 +10,16 @@ const DEFAULT_PROXY_PORTS: Record<string, number> = {
|
||||
wss: 443,
|
||||
};
|
||||
|
||||
export interface NodeHttpProxyAgents {
|
||||
httpAgent: HttpAgent;
|
||||
httpsAgent: HttpsAgent;
|
||||
}
|
||||
|
||||
export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE =
|
||||
"Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL.";
|
||||
|
||||
function getProxyEnv(key: string): string {
|
||||
return process.env[key.toLowerCase()] || process.env[key.toUpperCase()] || "";
|
||||
function getProxyEnv(key: string, env?: ProviderEnv): string {
|
||||
const lowercaseKey = key.toLowerCase();
|
||||
const uppercaseKey = key.toUpperCase();
|
||||
return (
|
||||
env?.[lowercaseKey] ||
|
||||
env?.[uppercaseKey] ||
|
||||
getProviderEnvValue(lowercaseKey) ||
|
||||
getProviderEnvValue(uppercaseKey) ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined {
|
||||
@@ -36,8 +34,8 @@ function parseProxyTargetUrl(targetUrl: string | URL): URL | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldProxyHostname(hostname: string, port: number): boolean {
|
||||
const noProxy = getProxyEnv("no_proxy").toLowerCase();
|
||||
function shouldProxyHostname(hostname: string, port: number, env?: ProviderEnv): boolean {
|
||||
const noProxy = getProxyEnv("no_proxy", env).toLowerCase();
|
||||
if (!noProxy) {
|
||||
return true;
|
||||
}
|
||||
@@ -68,7 +66,7 @@ function shouldProxyHostname(hostname: string, port: number): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
function getProxyForUrl(targetUrl: string | URL): string {
|
||||
function getProxyForUrl(targetUrl: string | URL, env?: ProviderEnv): string {
|
||||
const parsedUrl = parseProxyTargetUrl(targetUrl);
|
||||
if (!parsedUrl?.protocol || !parsedUrl.host) {
|
||||
return "";
|
||||
@@ -77,19 +75,22 @@ function getProxyForUrl(targetUrl: string | URL): string {
|
||||
const protocol = parsedUrl.protocol.split(":", 1)[0]!;
|
||||
const hostname = parsedUrl.host.replace(/:\d*$/, "");
|
||||
const port = Number.parseInt(parsedUrl.port, 10) || DEFAULT_PROXY_PORTS[protocol] || 0;
|
||||
if (!shouldProxyHostname(hostname, port)) {
|
||||
if (!shouldProxyHostname(hostname, port, env)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let proxy = getProxyEnv(`${protocol}_proxy`) || getProxyEnv("all_proxy");
|
||||
let proxy = getProxyEnv(`${protocol}_proxy`, env) || getProxyEnv("all_proxy", env);
|
||||
if (proxy && !proxy.includes("://")) {
|
||||
proxy = `${protocol}://${proxy}`;
|
||||
}
|
||||
return proxy;
|
||||
}
|
||||
|
||||
export function resolveHttpProxyUrlForTarget(targetUrl: string | URL): URL | undefined {
|
||||
const proxy = getProxyForUrl(targetUrl);
|
||||
export const UNSUPPORTED_PROXY_PROTOCOL_MESSAGE =
|
||||
"Unsupported proxy protocol. SOCKS and PAC proxy URLs are not supported; use an HTTP or HTTPS proxy URL.";
|
||||
|
||||
export function resolveHttpProxyUrlForTarget(targetUrl: string | URL, env?: ProviderEnv): URL | undefined {
|
||||
const proxy = getProxyForUrl(targetUrl, env);
|
||||
if (!proxy) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -109,15 +110,3 @@ export function resolveHttpProxyUrlForTarget(targetUrl: string | URL): URL | und
|
||||
|
||||
return proxyUrl;
|
||||
}
|
||||
|
||||
export function createHttpProxyAgentsForTarget(targetUrl: string | URL): NodeHttpProxyAgents | undefined {
|
||||
const proxyUrl = resolveHttpProxyUrlForTarget(targetUrl);
|
||||
if (!proxyUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
httpAgent: new HttpProxyAgent(proxyUrl),
|
||||
httpsAgent: new HttpsProxyAgent(proxyUrl) as unknown as HttpsAgent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import type { Server } from "node:http";
|
||||
import type { OAuthAuth } from "../../auth/types.ts";
|
||||
import { getProviderEnvValue } from "../provider-env.ts";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
|
||||
import { generatePKCE } from "./pkce.ts";
|
||||
import type { OAuthCredentials, OAuthLoginCallbacks, OAuthPrompt, OAuthProviderInterface } from "./types.ts";
|
||||
@@ -29,7 +30,7 @@ const decode = (s: string) => atob(s);
|
||||
const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
||||
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
|
||||
const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
||||
const CALLBACK_HOST = process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1";
|
||||
const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
|
||||
const CALLBACK_PORT = 53692;
|
||||
const CALLBACK_PATH = "/callback";
|
||||
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { OAuthCredentials, OAuthDeviceCodeInfo, OAuthLoginCallbacks, OAuthP
|
||||
|
||||
type CopilotCredentials = OAuthCredentials & {
|
||||
enterpriseUrl?: string;
|
||||
availableModelIds: string[];
|
||||
};
|
||||
|
||||
const decode = (s: string) => atob(s);
|
||||
@@ -21,6 +22,7 @@ const COPILOT_HEADERS = {
|
||||
"Editor-Plugin-Version": "copilot-chat/0.35.0",
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
} as const;
|
||||
const COPILOT_API_VERSION = "2026-06-01";
|
||||
|
||||
type DeviceCodeResponse = {
|
||||
device_code: string;
|
||||
@@ -89,6 +91,48 @@ export function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: strin
|
||||
return "https://api.individual.githubcopilot.com";
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : undefined;
|
||||
}
|
||||
|
||||
function isSelectableCopilotModel(item: Record<string, unknown>): boolean {
|
||||
const policy = asRecord(item.policy);
|
||||
const capabilities = asRecord(item.capabilities);
|
||||
const supports = asRecord(capabilities?.supports);
|
||||
return item.model_picker_enabled === true && policy?.state !== "disabled" && supports?.tool_calls !== false;
|
||||
}
|
||||
|
||||
function parseAvailableCopilotModelIds(raw: unknown): string[] {
|
||||
const data = asRecord(raw)?.data;
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error("Invalid Copilot models response");
|
||||
}
|
||||
|
||||
const ids: string[] = [];
|
||||
for (const rawItem of data) {
|
||||
const item = asRecord(rawItem);
|
||||
const id = item?.id;
|
||||
if (typeof id === "string" && item && isSelectableCopilotModel(item)) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function fetchAvailableGitHubCopilotModelIds(copilotToken: string, enterpriseDomain?: string): Promise<string[]> {
|
||||
const baseUrl = getGitHubCopilotBaseUrl(copilotToken, enterpriseDomain);
|
||||
const raw = await fetchJson(`${baseUrl}/models`, {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${copilotToken}`,
|
||||
...COPILOT_HEADERS,
|
||||
"X-GitHub-Api-Version": COPILOT_API_VERSION,
|
||||
},
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return parseAvailableCopilotModelIds(raw);
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
@@ -202,10 +246,7 @@ async function pollForGitHubAccessToken(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh GitHub Copilot token
|
||||
*/
|
||||
export async function refreshGitHubCopilotToken(
|
||||
async function refreshGitHubCopilotAccessToken(
|
||||
refreshToken: string,
|
||||
enterpriseDomain?: string,
|
||||
): Promise<OAuthCredentials> {
|
||||
@@ -239,6 +280,20 @@ export async function refreshGitHubCopilotToken(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh GitHub Copilot token
|
||||
*/
|
||||
export async function refreshGitHubCopilotToken(
|
||||
refreshToken: string,
|
||||
enterpriseDomain?: string,
|
||||
): Promise<OAuthCredentials> {
|
||||
const credentials = await refreshGitHubCopilotAccessToken(refreshToken, enterpriseDomain);
|
||||
return {
|
||||
...credentials,
|
||||
availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a model for the user's GitHub Copilot account.
|
||||
* This is required for some models (like Claude, Grok) before they can be used.
|
||||
@@ -323,12 +378,18 @@ export async function loginGitHubCopilot(options: {
|
||||
});
|
||||
|
||||
const githubAccessToken = await pollForGitHubAccessToken(domain, device, options.signal);
|
||||
const credentials = await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined);
|
||||
const credentials = await refreshGitHubCopilotAccessToken(githubAccessToken, enterpriseDomain ?? undefined);
|
||||
|
||||
// Enable all models after successful login
|
||||
options.onProgress?.("Enabling models...");
|
||||
await enableAllGitHubCopilotModels(credentials.access, enterpriseDomain ?? undefined);
|
||||
return credentials;
|
||||
|
||||
// Fetch availability after policy enable so newly enabled models are included,
|
||||
// while unavailable models are still filtered out.
|
||||
return {
|
||||
...credentials,
|
||||
availableModelIds: await fetchAvailableGitHubCopilotModelIds(credentials.access, enterpriseDomain ?? undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function copilotEnterpriseDomain(credential: OAuthCredential): string | undefined {
|
||||
@@ -393,6 +454,14 @@ export const githubCopilotOAuthProvider: OAuthProviderInterface = {
|
||||
const creds = credentials as CopilotCredentials;
|
||||
const domain = creds.enterpriseUrl ? (normalizeDomain(creds.enterpriseUrl) ?? undefined) : undefined;
|
||||
const baseUrl = getGitHubCopilotBaseUrl(creds.access, domain);
|
||||
return models.map((m) => (m.provider === "github-copilot" ? { ...m, baseUrl } : m));
|
||||
// Older stored Pi auth entries do not have account-specific model IDs yet;
|
||||
// keep their existing generated-catalog behavior until the next refresh/login.
|
||||
const availableModelIds = "availableModelIds" in creds ? new Set(creds.availableModelIds) : undefined;
|
||||
|
||||
return models.flatMap((m) => {
|
||||
if (m.provider !== "github-copilot") return [m];
|
||||
if (availableModelIds && !availableModelIds.has(m.id)) return [];
|
||||
return [{ ...m, baseUrl }];
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ if (typeof process !== "undefined" && (process.versions?.node || process.version
|
||||
}
|
||||
|
||||
import type { OAuthAuth } from "../../auth/types.ts";
|
||||
import { getProviderEnvValue } from "../provider-env.ts";
|
||||
import { pollOAuthDeviceCodeFlow } from "./device-code.ts";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "./oauth-page.ts";
|
||||
import { generatePKCE } from "./pkce.ts";
|
||||
@@ -48,7 +49,7 @@ type OAuthToken = { access: string; refresh: string; expires: number };
|
||||
type TokenOperation = "exchange" | "refresh";
|
||||
|
||||
function getCallbackHost(): string {
|
||||
return typeof process !== "undefined" ? process.env.PI_OAUTH_CALLBACK_HOST || "127.0.0.1" : "127.0.0.1";
|
||||
return getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
|
||||
}
|
||||
|
||||
type DeviceAuthInfo = {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { AssistantMessage } from "../types.ts";
|
||||
* - Anthropic: "413 {\"error\":{\"type\":\"request_too_large\",\"message\":\"Request exceeds the maximum size\"}}"
|
||||
* - OpenAI: "Your input exceeds the context window of this model"
|
||||
* - OpenAI/LiteLLM: "Requested token count exceeds the model's maximum context length of 131072 tokens"
|
||||
* - OpenAI-compatible: "Input length (265330) exceeds model's maximum context length (262144)."
|
||||
* - Google: "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)"
|
||||
* - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens"
|
||||
* - Groq: "Please reduce the length of the messages or completion"
|
||||
@@ -36,7 +37,7 @@ const OVERFLOW_PATTERNS = [
|
||||
/request_too_large/i, // Anthropic request byte-size overflow (HTTP 413)
|
||||
/input is too long for requested model/i, // Amazon Bedrock
|
||||
/exceeds the context window/i, // OpenAI (Completions & Responses API)
|
||||
/exceeds (?:the )?(?:model'?s )?maximum context length of [\d,]+ tokens?/i, // OpenAI-compatible proxies (LiteLLM)
|
||||
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, // OpenAI-compatible proxies (LiteLLM)
|
||||
/input token count.*exceeds the maximum/i, // Google (Gemini)
|
||||
/maximum prompt length is \d+/i, // xAI (Grok)
|
||||
/reduce the length of the messages/i, // Groq
|
||||
@@ -85,7 +86,7 @@ const NON_OVERFLOW_PATTERNS = [
|
||||
*
|
||||
* **Reliable detection (returns error with detectable message):**
|
||||
* - Anthropic: "prompt is too long: X tokens > Y maximum" or "request_too_large"
|
||||
* - OpenAI (Completions & Responses): "exceeds the context window" or "exceeds the model's maximum context length of X tokens"
|
||||
* - OpenAI (Completions & Responses): "exceeds the context window", "exceeds the model's maximum context length of X tokens", or "exceeds model's maximum context length (X)"
|
||||
* - Google Gemini: "input token count exceeds the maximum"
|
||||
* - xAI (Grok): "maximum prompt length is X but request contains Y"
|
||||
* - Groq: "reduce the length of the messages"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ProviderEnv } from "../types.ts";
|
||||
|
||||
let procEnvCache: Map<string, string> | null = null;
|
||||
|
||||
/**
|
||||
* Fallback for https://github.com/oven-sh/bun/issues/27802.
|
||||
* Bun compiled binaries can expose an empty process.env inside Linux sandboxes
|
||||
* even though /proc/self/environ contains the environment.
|
||||
*
|
||||
* This intentionally duplicates restoreSandboxEnv() in
|
||||
* packages/coding-agent/src/bun/restore-sandbox-env.ts. The ai package can be
|
||||
* used directly, without going through that entrypoint, so provider env lookup
|
||||
* must not depend on process.env having been patched.
|
||||
*/
|
||||
function getBunSandboxEnvValue(name: string): string | undefined {
|
||||
if (typeof process === "undefined" || !process.versions?.bun || Object.keys(process.env).length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (procEnvCache === null) {
|
||||
procEnvCache = new Map();
|
||||
try {
|
||||
const { readFileSync } = require("node:fs") as {
|
||||
readFileSync(path: string, encoding: BufferEncoding): string;
|
||||
};
|
||||
const data = readFileSync("/proc/self/environ", "utf-8");
|
||||
for (const entry of data.split("\0")) {
|
||||
const idx = entry.indexOf("=");
|
||||
if (idx > 0) {
|
||||
procEnvCache.set(entry.slice(0, idx), entry.slice(idx + 1));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// /proc/self/environ may not exist or may not be readable.
|
||||
}
|
||||
}
|
||||
|
||||
return procEnvCache.get(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a provider env value from scoped overrides, normal process.env, then
|
||||
* the duplicated Bun sandbox fallback for direct pi-ai consumers.
|
||||
*/
|
||||
export function getProviderEnvValue(name: string, env?: ProviderEnv): string | undefined {
|
||||
return (
|
||||
env?.[name] ||
|
||||
(typeof process !== "undefined" ? process.env[name] : undefined) ||
|
||||
getBunSandboxEnvValue(name) ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user