diff --git a/.gitignore b/.gitignore index e4079cd..6f9f222 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist/ .DS_Store *.log package-lock.json +.sisyphus/ diff --git a/index.ts b/index.ts index 1c0cbb4..8ebfa9b 100755 --- a/index.ts +++ b/index.ts @@ -137,6 +137,13 @@ const graphMemoryPlugin = { ? cfg.llm.apiKey // If apiKey set but no baseURL, assume Anthropic direct : undefined; const llm = createCompleteFn(provider, model, cfg.llm, anthropicApiKey); + if (cfg.llm?.auth === "oauth") { + if (!cfg.llm.oauthPath) { + api.logger.error("[graph-memory] OAuth mode enabled but llm.oauthPath is missing — LLM calls will fail"); + } else { + api.logger.info("[graph-memory] OAuth mode enabled"); + } + } const recaller = new Recaller(db, cfg); const extractor = new Extractor(cfg, llm); diff --git a/openclaw.plugin.json b/openclaw.plugin.json index 95d04a6..5d655e5 100755 --- a/openclaw.plugin.json +++ b/openclaw.plugin.json @@ -60,9 +60,13 @@ "type": "object", "description": "可选:LLM 配置。不配则用 OpenClaw 全局 provider", "properties": { - "apiKey": { "type": "string" }, - "baseURL": { "type": "string" }, - "model": { "type": "string" } + "apiKey": { "type": "string", "description": "API Key(传统认证)" }, + "baseURL": { "type": "string", "description": "API 地址" }, + "model": { "type": "string", "description": "模型名称" }, + "auth": { "type": "string", "enum": ["api-key", "oauth"], "default": "api-key", "description": "认证模式:api-key(默认)或 oauth" }, + "oauthPath": { "type": "string", "description": "OAuth 会话文件路径(auth=oauth 时必填)" }, + "oauthProvider": { "type": "string", "default": "openai-codex", "description": "OAuth 提供商标识" }, + "timeoutMs": { "type": "integer", "default": 30000, "description": "请求超时(毫秒)" } } } } diff --git a/package.json b/package.json index 3f271fa..1a496bb 100755 --- a/package.json +++ b/package.json @@ -10,14 +10,13 @@ "test:watch": "vitest" }, "dependencies": { - "@photostructure/sqlite": "^1.0.0", - "@sinclair/typebox": "^0.34.48", - "openai": "^4.47.0" + "@photostructure/sqlite": "^1.2.0", + "@sinclair/typebox": "^0.34.49" }, "devDependencies": { - "@types/node": "^20.0.0", - "typescript": "^5.4.0", - "vitest": "^1.4.0" + "@types/node": "^22.19.17", + "typescript": "^5.9.0", + "vitest": "^4.1.4" }, "peerDependencies": { "openclaw": "*" @@ -28,4 +27,4 @@ ], "hooks": {} } -} \ No newline at end of file +} diff --git a/src/engine/llm.ts b/src/engine/llm.ts index de7fd48..dafc612 100755 --- a/src/engine/llm.ts +++ b/src/engine/llm.ts @@ -10,14 +10,30 @@ * * 路径 A:pluginConfig.llm 配置直接调 OpenAI 兼容 API * 路径 B:直接调 Anthropic REST API(需 ANTHROPIC_API_KEY) + * 路径 C:OAuth Codex Responses API(需 llm.auth="oauth") * * 内置:429/5xx 重试 3 次 + 30s 超时 */ +import { + loadOAuthSession, + needsRefresh, + refreshOAuthSession, + saveOAuthSession, + normalizeOauthModel, + buildOauthEndpoint, + extractOutputTextFromSse, +} from "./oauth.js"; +import type { OAuthSession } from "./oauth.js"; + export interface LlmConfig { apiKey?: string; baseURL?: string; model?: string; + auth?: "api-key" | "oauth"; + oauthPath?: string; + oauthProvider?: string; + timeoutMs?: number; } export type CompleteFn = (system: string, user: string) => Promise; @@ -52,7 +68,111 @@ export function createCompleteFn( llmConfig?: LlmConfig, anthropicApiKey?: string, ): CompleteFn { + // ── Pre-resolve OAuth config to avoid non-null assertions in hot path ── + const oauthPath = llmConfig?.auth === "oauth" ? llmConfig.oauthPath : undefined; + const oauthTimeout = llmConfig?.timeoutMs; + + // ── OAuth session cache ─────────────────────────────────── + let cachedSessionPromise: Promise | null = null; + let refreshPromise: Promise | null = null; + + async function getOAuthSession(): Promise { + if (!oauthPath) { + throw new Error("[graph-memory] OAuth mode requires llm.oauthPath"); + } + if (!cachedSessionPromise) { + cachedSessionPromise = loadOAuthSession(oauthPath).catch((error) => { + cachedSessionPromise = null; + throw error; + }); + } + let session = await cachedSessionPromise; + if (needsRefresh(session)) { + if (!refreshPromise) { + refreshPromise = refreshOAuthSession(session, oauthTimeout) + .then(async (s) => { + await saveOAuthSession(oauthPath, s); + cachedSessionPromise = Promise.resolve(s); + refreshPromise = null; + return s; + }) + .catch((err) => { + refreshPromise = null; + throw err; + }); + } + session = await refreshPromise; + } + return session; + } + return async (system, user) => { + // ── 路径 C(OAuth):Codex Responses API ──────────────── + if (llmConfig?.auth === "oauth") { + if (!llmConfig.oauthPath) { + throw new Error("[graph-memory] OAuth mode requires llm.oauthPath"); + } + const session = await getOAuthSession(); + const endpoint = buildOauthEndpoint(llmConfig.baseURL, llmConfig.oauthProvider); + const oauthModel = normalizeOauthModel(llmConfig.model ?? model); + + const res = await fetchRetry(endpoint, { + method: "POST", + headers: { + "Authorization": `Bearer ${session.accessToken}`, + "Content-Type": "application/json", + "Accept": "text/event-stream", + "OpenAI-Beta": "responses=experimental", + "chatgpt-account-id": session.accountId, + "originator": "codex_cli_rs", + }, + body: JSON.stringify({ + model: oauthModel, + instructions: system.trim(), + input: [ + { + role: "user", + content: [{ type: "input_text", text: user }], + }, + ], + store: false, + stream: false, + text: { format: { type: "text" } }, + }), + }, 3, llmConfig.timeoutMs ?? 30_000); + + if (!res.ok) { + const errText = await res.text().catch(() => ""); + throw new Error(`[graph-memory] OAuth LLM API ${res.status}: ${errText.slice(0, 500)}`); + } + + const bodyText = await res.text(); + + // Non-streaming: parse as JSON and extract output text + let text: string | null = null; + try { + const parsed = JSON.parse(bodyText) as Record; + const output = Array.isArray(parsed.output) ? parsed.output : []; + for (const item of output) { + if (!item || typeof item !== "object") continue; + const content = Array.isArray((item as Record).content) + ? (item as Record).content as Array> + : []; + for (const part of content) { + if (part?.type === "output_text" && typeof part.text === "string") { + text = (text ?? "") + part.text; + } + } + } + } catch { + // fallback: try SSE parsing in case server ignored stream:false + text = extractOutputTextFromSse(bodyText); + } + + if (text) return text; + throw new Error("[graph-memory] OAuth LLM returned empty content"); + } + // ── 路径 A(优先):pluginConfig.llm 直接调 OpenAI 兼容 API ── if (llmConfig?.apiKey && llmConfig?.baseURL) { const baseURL = llmConfig.baseURL.replace(/\/+$/, ""); diff --git a/src/engine/oauth.ts b/src/engine/oauth.ts new file mode 100644 index 0000000..8394f7c --- /dev/null +++ b/src/engine/oauth.ts @@ -0,0 +1,720 @@ +/** + * graph-memory — OAuth authentication for LLM calls + * + * Ported from memory-lancedb-pro/src/llm-oauth.ts + * + * Supports OpenAI Codex Responses API with OAuth bearer tokens + * obtained via PKCE flow against auth.openai.com. + */ + +import { createHash, randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { platform } from "node:os"; +import { spawn } from "node:child_process"; + +// ─── Types ──────────────────────────────────────────────────── + +/** Config-level overrides for OAuth provider endpoints (replaces process.env). */ +export interface OAuthOverrides { + clientId?: string; + authorizeUrl?: string; + tokenUrl?: string; + redirectUri?: string; +} + +export interface OAuthLoginOptions { + authPath: string; + timeoutMs?: number; + noBrowser?: boolean; + model?: string; + providerId?: string; + overrides?: OAuthOverrides; + onOpenUrl?: (url: string) => void | Promise; + onAuthorizeUrl?: (url: string) => void | Promise; +} + +const EXPIRY_SKEW_MS = 60_000; + +export type OAuthProviderId = "openai-codex"; + +interface OAuthProviderDefinition { + id: OAuthProviderId; + label: string; + authorizeUrl: string; + tokenUrl: string; + clientId: string; + redirectUri: string; + scope: string; + accountIdClaim: string; + backendBaseUrl: string; + defaultModel: string; + modelPattern: RegExp; + extraAuthorizeParams?: Record; +} + +export interface OAuthSession { + accessToken: string; + refreshToken?: string; + expiresAt?: number; + accountId: string; + providerId: OAuthProviderId; + authPath: string; +} + +interface TokenRefreshResponse { + access_token?: string; + refresh_token?: string; + expires_in?: number; +} + +// ─── Provider definitions ───────────────────────────────────── + +const DEFAULT_OAUTH_PROVIDER_ID: OAuthProviderId = "openai-codex"; +const OAUTH_PROVIDER_ALIASES: Record = { + openai: "openai-codex", + codex: "openai-codex", + "openai-codex": "openai-codex", +}; +const OAUTH_PROVIDERS: Record = { + "openai-codex": { + id: "openai-codex", + label: "OpenAI Codex", + authorizeUrl: "https://auth.openai.com/oauth/authorize", + tokenUrl: "https://auth.openai.com/oauth/token", + clientId: "app_EMoamEEZ73f0CkXaXp7hrann", + redirectUri: "http://localhost:1455/auth/callback", + scope: "openid profile email offline_access", + accountIdClaim: "https://api.openai.com/auth", + backendBaseUrl: "https://chatgpt.com/backend-api", + defaultModel: "gpt-5.4", + modelPattern: /^(gpt-|o[1345]\b|o\d-mini\b|gpt-5|gpt-4|gpt-4o|gpt-5-codex|gpt-5\.1-codex)/i, + extraAuthorizeParams: { + id_token_add_organizations: "true", + codex_cli_simplified_flow: "true", + originator: "codex_cli_rs", + }, + }, +}; + +// ─── Helpers ────────────────────────────────────────────────── + +function parseNumericTimestamp(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value) && value > 0) { + return value > 1_000_000_000_000 ? value : value * 1000; + } + + if (typeof value === "string") { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = Number(trimmed); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed > 1_000_000_000_000 ? parsed : parsed * 1000; + } + } + + return undefined; +} + +function toBase64Url(value: Buffer): string { + return value.toString("base64url"); +} + +function createState(): string { + return randomBytes(16).toString("hex"); +} + +function createPkceVerifier(): string { + return toBase64Url(randomBytes(32)); +} + +function createPkceChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +function decodeJwtPayload(token: string): Record | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + return JSON.parse(Buffer.from(parts[1], "base64").toString("utf8")) as Record; + } catch { + return null; + } +} + +function getJwtExpiry(token: string): number | undefined { + const payload = decodeJwtPayload(token); + return parseNumericTimestamp(payload?.exp); +} + +function getJwtAccountId(token: string, providerId?: string): string | undefined { + const provider = getOAuthProvider(providerId); + const payload = decodeJwtPayload(token); + const claims = payload?.[provider.accountIdClaim]; + if (!claims || typeof claims !== "object") return undefined; + + const accountId = (claims as Record).chatgpt_account_id; + return typeof accountId === "string" && accountId.trim() ? accountId : undefined; +} + +function pickString(container: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = container[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + return undefined; +} + +function pickTimestamp(container: Record, keys: string[]): number | undefined { + for (const key of keys) { + const parsed = parseNumericTimestamp(container[key]); + if (parsed) return parsed; + } + return undefined; +} + +function createTimeoutSignal(timeoutMs?: number): { signal: AbortSignal; dispose: () => void } { + const effectiveTimeoutMs = + typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 30_000; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), effectiveTimeoutMs); + return { + signal: controller.signal, + dispose: () => clearTimeout(timer), + }; +} + +// ─── Provider resolution ────────────────────────────────────── + +export function listOAuthProviders(): Array> { + return Object.values(OAUTH_PROVIDERS).map((provider) => ({ + id: provider.id, + label: provider.label, + defaultModel: provider.defaultModel, + })); +} + +export function normalizeOAuthProviderId(providerId?: string): OAuthProviderId { + const raw = providerId?.trim().toLowerCase(); + if (!raw) return DEFAULT_OAUTH_PROVIDER_ID; + const resolved = OAUTH_PROVIDER_ALIASES[raw]; + if (resolved) return resolved; + const available = listOAuthProviders().map((provider) => provider.id).join(", "); + throw new Error(`Unsupported OAuth provider "${providerId}". Available providers: ${available}`); +} + +export function getOAuthProvider(providerId?: string): OAuthProviderDefinition { + return OAUTH_PROVIDERS[normalizeOAuthProviderId(providerId)]; +} + +export function getOAuthProviderLabel(providerId?: string): string { + return getOAuthProvider(providerId).label; +} + +export function getDefaultOauthModelForProvider(providerId?: string): string { + return getOAuthProvider(providerId).defaultModel; +} + +export function isOauthModelSupported(providerId: string | undefined, value: string | undefined): boolean { + if (!value || !value.trim()) return false; + const provider = getOAuthProvider(providerId); + const trimmed = value.trim(); + const slashIndex = trimmed.indexOf("/"); + if (slashIndex !== -1) { + const modelProvider = trimmed.slice(0, slashIndex).trim().toLowerCase(); + if (provider.id === "openai-codex" && modelProvider !== "openai" && modelProvider !== "openai-codex") { + return false; + } + } + + return provider.modelPattern.test(normalizeOauthModel(trimmed)); +} + +// ─── Configurable overrides (no process.env) ────────────────── + +function resolveOauthClientId(overrides: OAuthOverrides | undefined, providerId?: string): string { + return overrides?.clientId?.trim() || getOAuthProvider(providerId).clientId; +} + +function resolveOauthAuthorizeUrl(overrides: OAuthOverrides | undefined, providerId?: string): string { + return overrides?.authorizeUrl?.trim() || getOAuthProvider(providerId).authorizeUrl; +} + +function resolveOauthTokenUrl(overrides: OAuthOverrides | undefined, providerId?: string): string { + return overrides?.tokenUrl?.trim() || getOAuthProvider(providerId).tokenUrl; +} + +function resolveOauthRedirectUri(overrides: OAuthOverrides | undefined, providerId?: string): string { + return overrides?.redirectUri?.trim() || getOAuthProvider(providerId).redirectUri; +} + +// ─── Authorization URL builder ──────────────────────────────── + +function buildAuthorizationUrl(state: string, verifier: string, providerId?: string, overrides?: OAuthOverrides): string { + const provider = getOAuthProvider(providerId); + const url = new URL(resolveOauthAuthorizeUrl(overrides, provider.id)); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", resolveOauthClientId(overrides, provider.id)); + url.searchParams.set("redirect_uri", resolveOauthRedirectUri(overrides, provider.id)); + url.searchParams.set("scope", provider.scope); + url.searchParams.set("code_challenge", createPkceChallenge(verifier)); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + for (const [key, value] of Object.entries(provider.extraAuthorizeParams || {})) { + url.searchParams.set(key, value); + } + return url.toString(); +} + +// ─── HTML helpers ───────────────────────────────────────────── + +function buildSuccessHtml(): string { + return [ + "", + "", + "

graph-memory OAuth complete

", + "

You can close this window and return to your terminal.

", + "", + ].join(""); +} + +function buildErrorHtml(message: string): string { + return [ + "", + "", + "

graph-memory OAuth failed

", + `

${message}

`, + "", + ].join(""); +} + +// ─── Session extraction from JSON ───────────────────────────── + +function extractSessionFromObject(source: Record, authPath: string): OAuthSession | null { + const scopes: Record[] = [ + source, + typeof source.tokens === "object" && source.tokens ? source.tokens as Record : {}, + typeof source.oauth === "object" && source.oauth ? source.oauth as Record : {}, + typeof source.openai === "object" && source.openai ? source.openai as Record : {}, + typeof source.chatgpt === "object" && source.chatgpt ? source.chatgpt as Record : {}, + typeof source.auth === "object" && source.auth ? source.auth as Record : {}, + typeof source.credentials === "object" && source.credentials ? source.credentials as Record : {}, + ]; + + let accessToken: string | undefined; + let refreshToken: string | undefined; + let expiresAt: number | undefined; + let accountId: string | undefined; + const providerRaw = pickString(source, ["provider", "oauth_provider", "oauthProvider"]); + let providerId: OAuthProviderId; + try { + providerId = normalizeOAuthProviderId(providerRaw); + } catch { + return null; + } + + for (const scope of scopes) { + accessToken ||= pickString(scope, ["access_token", "accessToken", "access", "token"]); + refreshToken ||= pickString(scope, ["refresh_token", "refreshToken", "refresh"]); + expiresAt ||= pickTimestamp(scope, ["expires_at", "expiresAt", "expires", "expires_on"]); + accountId ||= pickString(scope, ["account_id", "accountId", "chatgpt_account_id", "chatgptAccountId"]); + } + + const apiKey = pickString(source, ["OPENAI_API_KEY", "api_key", "apiKey"]); + if (!accessToken && apiKey) { + return null; + } + + if (!accessToken) return null; + + accountId ||= getJwtAccountId(accessToken, providerId); + if (!accountId) return null; + + expiresAt ||= getJwtExpiry(accessToken); + + return { + accessToken, + refreshToken, + expiresAt, + accountId, + providerId, + authPath, + }; +} + +// ─── Session load / refresh / save ──────────────────────────── + +export async function loadOAuthSession(authPath: string): Promise { + let raw: string; + try { + raw = await readFile(authPath, "utf8"); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error( + `LLM OAuth requires a project OAuth file. Expected ${authPath}. Read failed: ${reason}`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw new Error(`Invalid project OAuth JSON at ${authPath}: ${reason}`); + } + + if (!parsed || typeof parsed !== "object") { + throw new Error(`Invalid project OAuth file at ${authPath}: expected a JSON object`); + } + + const session = extractSessionFromObject(parsed as Record, authPath); + if (!session) { + throw new Error( + `Project OAuth file at ${authPath} does not contain an OAuth access token and ChatGPT account id.`, + ); + } + + return session; +} + +export function needsRefresh(session: OAuthSession): boolean { + return !!session.refreshToken && !!session.expiresAt && session.expiresAt - EXPIRY_SKEW_MS <= Date.now(); +} + +export async function refreshOAuthSession(session: OAuthSession, timeoutMs?: number): Promise { + if (!session.refreshToken) { + throw new Error( + `OAuth session from ${session.authPath} is expired and has no refresh token. Re-run \`codex login\`.`, + ); + } + + const { signal, dispose } = createTimeoutSignal(timeoutMs); + try { + const response = await fetch(resolveOauthTokenUrl(undefined, session.providerId), { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: session.refreshToken, + client_id: resolveOauthClientId(undefined, session.providerId), + }), + signal, + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(`OAuth refresh failed (${response.status}): ${detail.slice(0, 500)}`); + } + + const payload = await response.json() as TokenRefreshResponse; + if (!payload.access_token) { + throw new Error("OAuth refresh returned no access token"); + } + + const accessToken = payload.access_token; + const refreshToken = payload.refresh_token || session.refreshToken; + const expiresAt = + typeof payload.expires_in === "number" + ? Date.now() + payload.expires_in * 1000 + : getJwtExpiry(accessToken); + const accountId = getJwtAccountId(accessToken, session.providerId) || session.accountId; + + if (!accountId) { + throw new Error("OAuth refresh returned a token without a ChatGPT account id"); + } + + return { + accessToken, + refreshToken, + expiresAt, + accountId, + providerId: session.providerId, + authPath: session.authPath, + }; + } finally { + dispose(); + } +} + +export async function saveOAuthSession(authPath: string, session: OAuthSession): Promise { + await mkdir(dirname(authPath), { recursive: true }); + const payload = { + provider: session.providerId, + type: "oauth", + access_token: session.accessToken, + refresh_token: session.refreshToken, + expires_at: session.expiresAt, + account_id: session.accountId, + updated_at: new Date().toISOString(), + }; + await writeFile(authPath, JSON.stringify(payload, null, 2) + "\n", { + encoding: "utf8", + mode: 0o600, + }); +} + +// ─── Model normalization ────────────────────────────────────── + +export function normalizeOauthModel(model: string): string { + const trimmed = model.trim(); + if (!trimmed) return trimmed; + + const slashIndex = trimmed.indexOf("/"); + if (slashIndex === -1) return trimmed; + + const provider = trimmed.slice(0, slashIndex).trim().toLowerCase(); + const modelName = trimmed.slice(slashIndex + 1).trim(); + if (!modelName) return trimmed; + + if (provider === "openai" || provider === "openai-codex") { + return modelName; + } + + return trimmed; +} + +// ─── Endpoint builder ───────────────────────────────────────── + +export function buildOauthEndpoint(baseURL?: string, providerId?: string): string { + const root = (baseURL?.trim() || getOAuthProvider(providerId).backendBaseUrl).replace(/\/+$/, ""); + if (root.endsWith("/codex/responses")) return root; + if (root.endsWith("/responses")) return root.replace(/\/responses$/, "/codex/responses"); + return `${root}/codex/responses`; +} + +// ─── SSE response parsing ───────────────────────────────────── + +function extractOutputTextFromResponsePayload(payload: unknown): string | null { + if (!payload || typeof payload !== "object") return null; + + const response = payload as Record; + const output = Array.isArray(response.output) ? response.output : null; + if (!output) return null; + + const texts: string[] = []; + for (const item of output) { + if (!item || typeof item !== "object") continue; + const content = Array.isArray((item as Record).content) + ? (item as Record).content as Array> + : []; + for (const part of content) { + if (part?.type === "output_text" && typeof part.text === "string") { + texts.push(part.text); + } + } + } + + return texts.length ? texts.join("\n") : null; +} + +export function extractOutputTextFromSse(bodyText: string): string | null { + const chunks = bodyText.split(/\r?\n\r?\n/); + let deltas = ""; + + for (const chunk of chunks) { + const dataLines = chunk + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trim()); + + if (!dataLines.length) continue; + + const data = dataLines.join("\n"); + if (!data || data === "[DONE]") continue; + + let payload: unknown; + try { + payload = JSON.parse(data); + } catch { + continue; + } + + if (!payload || typeof payload !== "object") continue; + + const event = payload as Record; + if (event.type === "response.output_text.delta" && typeof event.delta === "string") { + deltas += event.delta; + continue; + } + + if (event.type === "response.output_text.done" && typeof event.text === "string") { + return event.text; + } + + const nested = typeof event.response === "object" && event.response + ? extractOutputTextFromResponsePayload(event.response) + : null; + if (nested) return nested; + + const direct = extractOutputTextFromResponsePayload(event); + if (direct) return direct; + } + + return deltas || null; +} + +// ─── Full OAuth login flow (CLI use) ────────────────────────── + +async function exchangeAuthorizationCode(code: string, verifier: string, providerId?: string): Promise { + const resolvedProviderId = normalizeOAuthProviderId(providerId); + const response = await fetch(resolveOauthTokenUrl(undefined, resolvedProviderId), { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: resolveOauthClientId(undefined, resolvedProviderId), + code, + code_verifier: verifier, + redirect_uri: resolveOauthRedirectUri(undefined, resolvedProviderId), + }), + }); + + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(`OAuth token exchange failed (${response.status}): ${detail.slice(0, 500)}`); + } + + const payload = await response.json() as TokenRefreshResponse; + if (!payload.access_token) { + throw new Error("OAuth token exchange returned no access token"); + } + + const accountId = getJwtAccountId(payload.access_token, resolvedProviderId); + if (!accountId) { + throw new Error("OAuth token exchange returned a token without a ChatGPT account id"); + } + + return { + accessToken: payload.access_token, + refreshToken: payload.refresh_token, + expiresAt: + typeof payload.expires_in === "number" + ? Date.now() + payload.expires_in * 1000 + : getJwtExpiry(payload.access_token), + accountId, + providerId: resolvedProviderId, + authPath: "", + }; +} + +function tryOpenBrowser(url: string): void { + const targetPlatform = platform(); + if (targetPlatform === "darwin") { + const child = spawn("open", [url], { detached: true, stdio: "ignore" }); + child.unref(); + return; + } + + if (targetPlatform === "win32") { + const child = spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }); + child.unref(); + return; + } + + const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" }); + child.unref(); +} + +export function resolveOAuthCallbackListenHost(redirectUri: URL | string): string { + const parsed = typeof redirectUri === "string" ? new URL(redirectUri) : redirectUri; + const hostname = parsed.hostname.trim(); + if (!hostname) return "127.0.0.1"; + return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; +} + +async function waitForAuthorizationCode(state: string, timeoutMs: number, providerId?: string): Promise { + const redirectUri = new URL(resolveOauthRedirectUri(undefined, providerId)); + const listenPort = Number(redirectUri.port || 80); + const callbackPath = redirectUri.pathname || "/"; + const listenHost = resolveOAuthCallbackListenHost(redirectUri); + + return await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + server.close(); + reject(new Error(`Timed out waiting for OAuth callback on ${redirectUri.origin}${callbackPath}`)); + }, timeoutMs); + + const server = createServer((req, res) => { + if (!req.url) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(buildErrorHtml("Missing callback URL.")); + return; + } + + const url = new URL(req.url, redirectUri.origin); + if (url.pathname !== callbackPath) { + res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" }); + res.end(buildErrorHtml("Unknown callback path.")); + return; + } + + const returnedState = url.searchParams.get("state"); + const code = url.searchParams.get("code"); + const error = url.searchParams.get("error"); + + if (error) { + clearTimeout(timer); + server.close(); + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(buildErrorHtml(`Authorization failed: ${error}`)); + reject(new Error(`OAuth authorization failed: ${error}`)); + return; + } + + if (!code || returnedState !== state) { + clearTimeout(timer); + server.close(); + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(buildErrorHtml("Invalid authorization callback.")); + reject(new Error("OAuth callback did not include a valid code/state pair")); + return; + } + + clearTimeout(timer); + server.close(); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(buildSuccessHtml()); + resolve(code); + }); + + server.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + + server.listen(listenPort, listenHost); + }); +} + +export async function performOAuthLogin(options: OAuthLoginOptions): Promise<{ session: OAuthSession; authorizeUrl: string }> { + const provider = getOAuthProvider(options.providerId); + const verifier = createPkceVerifier(); + const state = createState(); + const authorizeUrl = buildAuthorizationUrl(state, verifier, provider.id); + + await options.onAuthorizeUrl?.(authorizeUrl); + if (!options.noBrowser) { + if (options.onOpenUrl) { + await options.onOpenUrl(authorizeUrl); + } else { + try { + tryOpenBrowser(authorizeUrl); + } catch { + // Browser opening is best-effort; caller still receives the URL. + } + } + } + + const code = await waitForAuthorizationCode(state, options.timeoutMs ?? 120_000, provider.id); + const session = await exchangeAuthorizationCode(code, verifier, provider.id); + session.authPath = options.authPath; + await saveOAuthSession(options.authPath, session); + return { session, authorizeUrl }; +} diff --git a/src/types.ts b/src/types.ts index 082c196..57c7895 100755 --- a/src/types.ts +++ b/src/types.ts @@ -132,6 +132,14 @@ export interface GmConfig { apiKey?: string; baseURL?: string; model?: string; + /** Authentication mode: "api-key" (default) or "oauth" */ + auth?: "api-key" | "oauth"; + /** Path to OAuth session JSON file (required when auth="oauth") */ + oauthPath?: string; + /** OAuth provider identifier (default: "openai-codex") */ + oauthProvider?: string; + /** Timeout for OAuth requests in ms (default: 30000) */ + timeoutMs?: number; }; /** 向量去重阈值,余弦相似度超过此值视为重复 (0-1) */ dedupThreshold: number; diff --git a/vitest.config.ts b/vitest.config.ts index 74e6f60..66a3fb6 100755 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,5 +4,6 @@ export default defineConfig({ test: { globals: true, testTimeout: 10_000, + include: ["test/**/*.test.ts"], }, });