mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 03:11:10 +00:00
* feat(discord): add autoThreadName 'generated' strategy
Adds async thread title generation for auto-created threads:
- autoThread: boolean - enables/disables auto-threading
- autoThreadName: 'message' | 'generated' - naming strategy
- 'generated' uses LLM to create concise 3-6 word titles
- Includes channel name/description context for better titles
- 10s timeout with graceful fallback
* Discord: support non-key auth for generated thread titles
* Discord: skip fallback auto-thread rename
* Discord: normalize generated thread title first content line
* Discord: split thread title generation helpers
* Discord: tidy thread title generation constants and order
* Discord: use runtime fallback model resolution for thread titles
* Discord: resolve thread-title model aliases
* Discord: fallback thread-title model selection to runtime defaults
* Agents: centralize simple completion runtime
* fix(discord): pass apiKey to complete() for thread title generation
The setRuntimeApiKey approach only works for full agent runs that use
authStorage.getApiKey(). The pi-ai complete() function expects apiKey
directly in options or falls back to env vars — it doesn't read from
authStorage.runtimeOverrides.
Fixes thread title generation for Claude/Anthropic users.
* fix(agents): return exchanged Copilot token from prepareSimpleCompletionModel
The recent thread-title fix (3346ba6) passes prepared.auth.apiKey to
complete(). For github-copilot, this was still the raw GitHub token
rather than the exchanged runtime token, causing auth failures.
Now setRuntimeApiKeyForCompletion returns the resolved token and
prepareSimpleCompletionModel includes it in auth.apiKey, so both the
authStorage path and direct apiKey pass-through work correctly.
* fix(agents): catch auth lookup exceptions in completion model prep
getApiKeyForModel can throw for credential issues (missing profile, etc).
Wrap in try/catch to return { error } for fail-soft handling rather than
propagating rejected promises to callers like thread title generation.
* Discord: strip markdown wrappers from generated thread titles
* Discord/agents: align thread-title model and local no-auth completion headers
* Tests: import fresh modules for mocked thread-title/simple-completion suites
* Agents: apply exchanged Copilot baseUrl in simple completions
* Discord: route thread runtime imports through plugin SDK
* Lockfile: add Discord pi-ai runtime dependency
* Lockfile: regenerate Discord pi-ai runtime dependency entries
* Agents: use published Copilot token runtime module
* Discord: refresh config baseline and lockfile
* Tests: split extension runs by isolation
* Discord: add changelog for generated thread titles (#43366) (thanks @davidguttman)
---------
Co-authored-by: Onur Solmaz <onur@textcortex.com>
Co-authored-by: Onur Solmaz <2453968+osolmaz@users.noreply.github.com>
138 lines
4.4 KiB
TypeScript
138 lines
4.4 KiB
TypeScript
import path from "node:path";
|
|
import { resolveStateDir } from "../config/paths.js";
|
|
import { loadJsonFile, saveJsonFile } from "../infra/json-file.js";
|
|
|
|
const COPILOT_TOKEN_URL = "https://api.github.com/copilot_internal/v2/token";
|
|
|
|
export type CachedCopilotToken = {
|
|
token: string;
|
|
/** milliseconds since epoch */
|
|
expiresAt: number;
|
|
/** milliseconds since epoch */
|
|
updatedAt: number;
|
|
};
|
|
|
|
function resolveCopilotTokenCachePath(env: NodeJS.ProcessEnv = process.env) {
|
|
return path.join(resolveStateDir(env), "credentials", "github-copilot.token.json");
|
|
}
|
|
|
|
function isTokenUsable(cache: CachedCopilotToken, now = Date.now()): boolean {
|
|
// Keep a small safety margin when checking expiry.
|
|
return 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");
|
|
}
|
|
|
|
// GitHub returns a unix timestamp (seconds), but we defensively accept ms too.
|
|
let expiresAtMs: number;
|
|
if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) {
|
|
expiresAtMs = expiresAt > 10_000_000_000 ? expiresAt : expiresAt * 1000;
|
|
} 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 > 10_000_000_000 ? parsed : parsed * 1000;
|
|
} else {
|
|
throw new Error("Copilot token response missing expires_at");
|
|
}
|
|
|
|
return { token, expiresAt: expiresAtMs };
|
|
}
|
|
|
|
export const DEFAULT_COPILOT_API_BASE_URL = "https://api.individual.githubcopilot.com";
|
|
|
|
export function deriveCopilotApiBaseUrlFromToken(token: string): string | null {
|
|
const trimmed = token.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
// The token returned from the Copilot token endpoint is a semicolon-delimited
|
|
// set of key/value pairs. One of them is `proxy-ep=...`.
|
|
const match = trimmed.match(/(?:^|;)\s*proxy-ep=([^;\s]+)/i);
|
|
const proxyEp = match?.[1]?.trim();
|
|
if (!proxyEp) {
|
|
return null;
|
|
}
|
|
|
|
// pi-ai expects converting proxy.* -> api.*
|
|
// (see upstream getGitHubCopilotBaseUrl).
|
|
const host = proxyEp.replace(/^https?:\/\//, "").replace(/^proxy\./i, "api.");
|
|
if (!host) {
|
|
return null;
|
|
}
|
|
|
|
return `https://${host}`;
|
|
}
|
|
|
|
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 (isTokenUsable(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}`,
|
|
},
|
|
});
|
|
|
|
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(),
|
|
};
|
|
saveJsonFileFn(cachePath, payload);
|
|
|
|
return {
|
|
token: payload.token,
|
|
expiresAt: payload.expiresAt,
|
|
source: `fetched:${COPILOT_TOKEN_URL}`,
|
|
baseUrl: deriveCopilotApiBaseUrlFromToken(payload.token) ?? DEFAULT_COPILOT_API_BASE_URL,
|
|
};
|
|
}
|