mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-21 00:04:46 +00:00
Fixes Copilot image understanding by exchanging OAuth tokens for Copilot API tokens, routing Copilot Gemini image requests through Chat Completions, and sending the prompt in user content with Copilot vision headers. Real behavior proof: - Old Responses route with real Copilot key reproduced `400 model gemini-3.1-pro-preview does not support Responses API`. - Fixed route with the same real Copilot key returned `Cat`. - Final CLI live smoke returned `ok: true` and `text: Cat` for `github-copilot/gemini-3.1-pro-preview`. Verification: - pnpm test src/media-understanding/image.test.ts extensions/github-copilot/models.test.ts extensions/github-copilot/stream.test.ts src/agents/pi-hooks/compaction-safeguard.test.ts -- --reporter=verbose - pnpm check:changed via Blacksmith Testbox tbx_01krgt56pqmft8txekt017wke6, Actions run https://github.com/openclaw/openclaw/actions/runs/25803926150, exit 0. Refs #80393, #80442. Co-authored-by: Yang Haoyu <150496764+afunnyhy@users.noreply.github.com>
346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
// Public auth/onboarding helpers for provider plugins.
|
|
|
|
import path from "node:path";
|
|
import { resolveDefaultAgentDir } from "../agents/agent-scope-config.js";
|
|
import { resolveApiKeyForProfile } from "../agents/auth-profiles/oauth.js";
|
|
import { resolveAuthProfileOrder } from "../agents/auth-profiles/order.js";
|
|
import { listProfilesForProvider } from "../agents/auth-profiles/profiles.js";
|
|
import { ensureAuthProfileStore } from "../agents/auth-profiles/store.js";
|
|
import { resolveEnvApiKey } from "../agents/model-auth-env.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import { resolveStateDir } from "../config/paths.js";
|
|
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
|
|
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
|
|
import { resolveProviderEndpoint } from "./provider-model-shared.js";
|
|
|
|
export type { OpenClawConfig } from "../config/config.js";
|
|
export type { SecretInput } from "../config/types.secrets.js";
|
|
export type { SecretInputMode } from "../plugins/provider-auth-types.js";
|
|
export type { ProviderAuthResult } from "../plugins/types.js";
|
|
export type { ProviderAuthContext } from "../plugins/types.js";
|
|
export type { AuthProfileStore, OAuthCredential } from "../agents/auth-profiles/types.js";
|
|
|
|
export { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "../agents/auth-profiles/constants.js";
|
|
export {
|
|
ensureAuthProfileStore,
|
|
ensureAuthProfileStoreForLocalUpdate,
|
|
updateAuthProfileStoreWithLock,
|
|
} from "../agents/auth-profiles/store.js";
|
|
export {
|
|
listProfilesForProvider,
|
|
removeProviderAuthProfilesWithLock,
|
|
upsertAuthProfile,
|
|
upsertAuthProfileWithLock,
|
|
} from "../agents/auth-profiles/profiles.js";
|
|
export { resolveEnvApiKey } from "../agents/model-auth-env.js";
|
|
export { readClaudeCliCredentialsCached } from "../agents/cli-credentials.js";
|
|
export { suggestOAuthProfileIdForLegacyDefault } from "../agents/auth-profiles/repair.js";
|
|
export {
|
|
CUSTOM_LOCAL_AUTH_MARKER,
|
|
MINIMAX_OAUTH_MARKER,
|
|
isKnownEnvApiKeyMarker,
|
|
isNonSecretApiKeyMarker,
|
|
resolveOAuthApiKeyMarker,
|
|
resolveNonEnvSecretRefApiKeyMarker,
|
|
} from "../agents/model-auth-markers.js";
|
|
export {
|
|
formatApiKeyPreview,
|
|
normalizeApiKeyInput,
|
|
validateApiKeyInput,
|
|
} from "../plugins/provider-auth-input.js";
|
|
export {
|
|
ensureApiKeyFromEnvOrPrompt,
|
|
ensureApiKeyFromOptionEnvOrPrompt,
|
|
normalizeSecretInputModeInput,
|
|
promptSecretRefForSetup,
|
|
resolveSecretInputModeForEnvSelection,
|
|
} from "../plugins/provider-auth-input.js";
|
|
export { normalizeApiKeyConfig } from "../agents/models-config.providers.secrets.js";
|
|
export {
|
|
buildTokenProfileId,
|
|
validateAnthropicSetupToken,
|
|
} from "../plugins/provider-auth-token.js";
|
|
export {
|
|
applyAuthProfileConfig,
|
|
buildApiKeyCredential,
|
|
upsertApiKeyProfile,
|
|
writeOAuthCredentials,
|
|
type ApiKeyStorageOptions,
|
|
type WriteOAuthCredentialsOptions,
|
|
} from "../plugins/provider-auth-helpers.js";
|
|
export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js";
|
|
export { coerceSecretRef, hasConfiguredSecretInput } from "../config/types.secrets.js";
|
|
export { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js";
|
|
export { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
|
export { resolveOpenClawAgentDir } from "./agent-dir-compat.js";
|
|
export {
|
|
normalizeOptionalSecretInput,
|
|
normalizeSecretInput,
|
|
} from "../utils/normalize-secret-input.js";
|
|
export {
|
|
listKnownProviderAuthEnvVarNames,
|
|
omitEnvKeysCaseInsensitive,
|
|
} from "../secrets/provider-env-vars.js";
|
|
export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
|
export {
|
|
generateHexPkceVerifierChallenge,
|
|
generatePkceVerifierChallenge,
|
|
toFormUrlEncoded,
|
|
} from "./oauth-utils.js";
|
|
export {
|
|
DEFAULT_OAUTH_REFRESH_MARGIN_MS,
|
|
hasUsableOAuthCredential,
|
|
} from "../agents/auth-profiles/credential-state.js";
|
|
|
|
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
|
|
|
|
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
|
|
export const COPILOT_EDITOR_VERSION = "vscode/1.107.0";
|
|
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
|
|
export const COPILOT_USER_AGENT = "GitHubCopilotChat/0.35.0";
|
|
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
|
|
export const COPILOT_EDITOR_PLUGIN_VERSION = "copilot-chat/0.35.0";
|
|
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
|
|
export const COPILOT_GITHUB_API_VERSION = "2025-04-01";
|
|
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
|
|
export const COPILOT_INTEGRATION_ID = "vscode-chat";
|
|
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
|
|
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
|
|
|
|
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
|
|
export type CachedCopilotToken = {
|
|
token: string;
|
|
expiresAt: number;
|
|
updatedAt: number;
|
|
integrationId?: string;
|
|
};
|
|
|
|
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
|
|
export function buildCopilotIdeHeaders(
|
|
params: {
|
|
includeApiVersion?: boolean;
|
|
} = {},
|
|
): Record<string, string> {
|
|
return {
|
|
"Editor-Version": COPILOT_EDITOR_VERSION,
|
|
"Editor-Plugin-Version": COPILOT_EDITOR_PLUGIN_VERSION,
|
|
"User-Agent": COPILOT_USER_AGENT,
|
|
...(params.includeApiVersion ? { "X-Github-Api-Version": COPILOT_GITHUB_API_VERSION } : {}),
|
|
};
|
|
}
|
|
|
|
function resolveCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env) {
|
|
return path.join(resolveStateDir(env), "credentials", "github-copilot.token.json");
|
|
}
|
|
|
|
function isCopilotTokenUsable(cache: CachedCopilotToken, now = Date.now()): boolean {
|
|
return cache.integrationId === COPILOT_INTEGRATION_ID && cache.expiresAt - now > 5 * 60 * 1000;
|
|
}
|
|
|
|
function parseCopilotTokenResponse(value: unknown): {
|
|
token: string;
|
|
expiresAt: number;
|
|
} {
|
|
if (!value || typeof value !== "object") {
|
|
throw new Error("Unexpected response from GitHub Copilot token endpoint");
|
|
}
|
|
const asRecord = value as Record<string, unknown>;
|
|
const token = asRecord.token;
|
|
const expiresAt = asRecord.expires_at;
|
|
if (typeof token !== "string" || token.trim().length === 0) {
|
|
throw new Error("Copilot token response missing token");
|
|
}
|
|
|
|
let expiresAtMs: number;
|
|
if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) {
|
|
expiresAtMs = expiresAt < 100_000_000_000 ? expiresAt * 1000 : expiresAt;
|
|
} else if (typeof expiresAt === "string" && expiresAt.trim().length > 0) {
|
|
const parsed = Number.parseInt(expiresAt, 10);
|
|
if (!Number.isFinite(parsed)) {
|
|
throw new Error("Copilot token response has invalid expires_at");
|
|
}
|
|
expiresAtMs = parsed < 100_000_000_000 ? parsed * 1000 : parsed;
|
|
} else {
|
|
throw new Error("Copilot token response missing expires_at");
|
|
}
|
|
|
|
return { token, expiresAt: expiresAtMs };
|
|
}
|
|
|
|
function resolveCopilotProxyHost(proxyEp: string): string | null {
|
|
const trimmed = proxyEp.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const urlText = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
|
|
try {
|
|
const url = new URL(urlText);
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
return null;
|
|
}
|
|
return normalizeLowercaseStringOrEmpty(url.hostname);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
|
|
export function deriveCopilotApiBaseUrlFromToken(token: string): string | null {
|
|
const trimmed = token.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const match = trimmed.match(/(?:^|;)\s*proxy-ep=([^;\s]+)/i);
|
|
const proxyEp = match?.[1]?.trim();
|
|
if (!proxyEp) {
|
|
return null;
|
|
}
|
|
|
|
const proxyHost = resolveCopilotProxyHost(proxyEp);
|
|
if (!proxyHost) {
|
|
return null;
|
|
}
|
|
const host = proxyHost.replace(/^proxy\./i, "api.");
|
|
|
|
const baseUrl = `https://${host}`;
|
|
return resolveProviderEndpoint(baseUrl).endpointClass === "invalid" ? null : baseUrl;
|
|
}
|
|
|
|
/** @deprecated GitHub Copilot provider-owned helper; do not use from third-party plugins. */
|
|
export async function resolveCopilotApiToken(params: {
|
|
githubToken: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
fetchImpl?: typeof fetch;
|
|
cachePath?: string;
|
|
loadJsonFileImpl?: (path: string) => unknown;
|
|
saveJsonFileImpl?: (path: string, value: CachedCopilotToken) => void;
|
|
}): Promise<{
|
|
token: string;
|
|
expiresAt: number;
|
|
source: string;
|
|
baseUrl: string;
|
|
}> {
|
|
const env = params.env ?? process.env;
|
|
const cachePath = params.cachePath?.trim() || resolveCopilotTokenCachePath(env);
|
|
const loadJsonFileFn = params.loadJsonFileImpl ?? loadJsonFile;
|
|
const saveJsonFileFn = params.saveJsonFileImpl ?? saveJsonFile;
|
|
const cached = loadJsonFileFn(cachePath) as CachedCopilotToken | undefined;
|
|
if (cached && typeof cached.token === "string" && typeof cached.expiresAt === "number") {
|
|
if (isCopilotTokenUsable(cached)) {
|
|
return {
|
|
token: cached.token,
|
|
expiresAt: cached.expiresAt,
|
|
source: `cache:${cachePath}`,
|
|
baseUrl: deriveCopilotApiBaseUrlFromToken(cached.token) ?? DEFAULT_COPILOT_API_BASE_URL,
|
|
};
|
|
}
|
|
}
|
|
|
|
const fetchImpl = params.fetchImpl ?? fetch;
|
|
const res = await fetchImpl(COPILOT_TOKEN_URL, {
|
|
method: "GET",
|
|
headers: {
|
|
Accept: "application/json",
|
|
Authorization: `Bearer ${params.githubToken}`,
|
|
"Copilot-Integration-Id": COPILOT_INTEGRATION_ID,
|
|
...buildCopilotIdeHeaders({ includeApiVersion: true }),
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
throw new Error(`Copilot token exchange failed: HTTP ${res.status}`);
|
|
}
|
|
|
|
const json = parseCopilotTokenResponse(await res.json());
|
|
const payload: CachedCopilotToken = {
|
|
token: json.token,
|
|
expiresAt: json.expiresAt,
|
|
updatedAt: Date.now(),
|
|
integrationId: COPILOT_INTEGRATION_ID,
|
|
};
|
|
saveJsonFileFn(cachePath, payload);
|
|
|
|
return {
|
|
token: payload.token,
|
|
expiresAt: payload.expiresAt,
|
|
source: `fetched:${COPILOT_TOKEN_URL}`,
|
|
baseUrl: deriveCopilotApiBaseUrlFromToken(payload.token) ?? DEFAULT_COPILOT_API_BASE_URL,
|
|
};
|
|
}
|
|
|
|
export function isProviderApiKeyConfigured(params: {
|
|
provider: string;
|
|
agentDir?: string;
|
|
}): boolean {
|
|
if (resolveEnvApiKey(params.provider)?.apiKey) {
|
|
return true;
|
|
}
|
|
const agentDir = params.agentDir?.trim();
|
|
if (!agentDir) {
|
|
return false;
|
|
}
|
|
const store = ensureAuthProfileStore(agentDir, {
|
|
allowKeychainPrompt: false,
|
|
});
|
|
return listProfilesForProvider(store, params.provider).length > 0;
|
|
}
|
|
|
|
export function listUsableProviderAuthProfileIds(params: {
|
|
provider: string;
|
|
cfg?: OpenClawConfig;
|
|
agentDir?: string;
|
|
}): { agentDir: string; profileIds: string[] } {
|
|
try {
|
|
const agentDir = params.agentDir?.trim() || resolveDefaultAgentDir(params.cfg ?? {});
|
|
const store = ensureAuthProfileStore(agentDir, {
|
|
allowKeychainPrompt: false,
|
|
});
|
|
return {
|
|
agentDir,
|
|
profileIds: resolveAuthProfileOrder({
|
|
cfg: params.cfg,
|
|
store,
|
|
provider: params.provider,
|
|
}),
|
|
};
|
|
} catch {
|
|
return { agentDir: "", profileIds: [] };
|
|
}
|
|
}
|
|
|
|
export function isProviderAuthProfileConfigured(params: {
|
|
provider: string;
|
|
cfg?: OpenClawConfig;
|
|
agentDir?: string;
|
|
}): boolean {
|
|
return listUsableProviderAuthProfileIds(params).profileIds.length > 0;
|
|
}
|
|
|
|
export async function resolveProviderAuthProfileApiKey(params: {
|
|
provider: string;
|
|
cfg?: OpenClawConfig;
|
|
agentDir?: string;
|
|
}): Promise<string | undefined> {
|
|
const { agentDir, profileIds } = listUsableProviderAuthProfileIds(params);
|
|
if (!agentDir || profileIds.length === 0) {
|
|
return undefined;
|
|
}
|
|
const store = ensureAuthProfileStore(agentDir, {
|
|
allowKeychainPrompt: false,
|
|
});
|
|
for (const profileId of profileIds) {
|
|
const resolved = await resolveApiKeyForProfile({
|
|
cfg: params.cfg,
|
|
store,
|
|
agentDir,
|
|
profileId,
|
|
});
|
|
if (resolved?.apiKey) {
|
|
return resolved.apiKey;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|