Shaheer Sarfaraz d0b4091a60
Ghostwriter Introduced (#166)
* initlal commit

* Ghostwriter always enabled

* rename code

* ghostwriter panel

* separate component

* ui improvements

* single thread

* copy improvement

* dont pop up keyboard shortcuts

* markdown renderer

* ghostwriter button placement

* better UX

* ghostwriter copy

* meta shortcut

* better settings menu

* formatting

* doocumentation

* add tests

* race condition

* race condition 2

* pass title

* more comments

* comments

* formtting
2026-02-15 22:03:37 +00:00

302 lines
8.1 KiB
TypeScript

import { logger } from "@infra/logger";
import { toStringOrNull } from "@shared/utils/type-conversion";
import {
buildModeCacheKey,
getOrderedModes,
rememberSuccessfulMode,
} from "./policies/mode-selection";
import { getRetryDelayMs, shouldRetryAttempt } from "./policies/retry-policy";
import { strategies } from "./providers";
import type {
JsonSchemaDefinition,
LlmApiError,
LlmProvider,
LlmRequestOptions,
LlmResponse,
LlmServiceOptions,
LlmValidationResult,
ResponseMode,
} from "./types";
import { buildHeaders, getResponseDetail } from "./utils/http";
import { parseJsonContent } from "./utils/json";
import { parseErrorMessage, truncate } from "./utils/string";
export class LlmService {
private readonly provider: LlmProvider;
private readonly baseUrl: string;
private readonly apiKey: string | null;
private readonly strategy: (typeof strategies)[LlmProvider];
constructor(options: LlmServiceOptions = {}) {
const normalizedBaseUrl =
toStringOrNull(options.baseUrl) ||
toStringOrNull(process.env.LLM_BASE_URL) ||
null;
const resolvedProvider = normalizeProvider(
options.provider ?? process.env.LLM_PROVIDER ?? null,
normalizedBaseUrl,
);
const strategy = strategies[resolvedProvider];
const baseUrl = normalizedBaseUrl || strategy.defaultBaseUrl;
let apiKey =
toStringOrNull(options.apiKey) ||
toStringOrNull(process.env.LLM_API_KEY) ||
null;
// Backwards-compat migration: OPENROUTER_API_KEY -> LLM_API_KEY.
// This prevents users from losing access when upgrading (keys are often only shown once).
if (
!apiKey &&
resolvedProvider === "openrouter" &&
toStringOrNull(process.env.OPENROUTER_API_KEY)
) {
logger.warn(
"[DEPRECATED] OPENROUTER_API_KEY is deprecated. Copying to LLM_API_KEY; please update your environment.",
);
const migrated = toStringOrNull(process.env.OPENROUTER_API_KEY);
if (migrated) {
process.env.LLM_API_KEY = migrated;
apiKey = migrated;
}
}
this.provider = resolvedProvider;
this.baseUrl = baseUrl;
this.apiKey = apiKey;
this.strategy = strategy;
}
async callJson<T>(options: LlmRequestOptions<T>): Promise<LlmResponse<T>> {
if (this.strategy.requiresApiKey && !this.apiKey) {
return { success: false, error: "LLM API key not configured" };
}
const {
model,
messages,
jsonSchema,
maxRetries = 0,
retryDelayMs = 500,
signal,
} = options;
const jobId = options.jobId;
const cacheKey = buildModeCacheKey(this.provider, this.baseUrl);
const modes = getOrderedModes(cacheKey, this.strategy.modes);
for (const mode of modes) {
const result = await this.tryMode<T>({
mode,
model,
messages,
jsonSchema,
maxRetries,
retryDelayMs,
jobId,
signal,
});
if (result.success) {
rememberSuccessfulMode(cacheKey, mode);
return result;
}
if (!result.success && result.error.startsWith("CAPABILITY:")) {
continue;
}
return result;
}
return { success: false, error: "All provider modes failed" };
}
getProvider(): LlmProvider {
return this.provider;
}
getBaseUrl(): string {
return this.baseUrl;
}
async validateCredentials(): Promise<LlmValidationResult> {
if (this.strategy.requiresApiKey && !this.apiKey) {
return { valid: false, message: "LLM API key is missing." };
}
const urls = this.strategy.getValidationUrls({
baseUrl: this.baseUrl,
apiKey: this.apiKey,
});
let lastMessage: string | null = null;
for (const url of urls) {
try {
const response = await fetch(url, {
method: "GET",
headers: buildHeaders({
apiKey: this.apiKey,
provider: this.provider,
}),
});
if (response.ok) {
return { valid: true, message: null };
}
const detail = await getResponseDetail(response);
if (response.status === 401) {
return {
valid: false,
message: "Invalid LLM API key. Check the key and try again.",
};
}
lastMessage = detail || `LLM provider returned ${response.status}`;
} catch (error) {
lastMessage =
error instanceof Error ? error.message : "LLM validation failed.";
}
}
return {
valid: false,
message: lastMessage || "LLM provider validation failed.",
};
}
private async tryMode<T>(args: {
mode: ResponseMode;
model: string;
messages: LlmRequestOptions<T>["messages"];
jsonSchema: JsonSchemaDefinition;
maxRetries: number;
retryDelayMs: number;
jobId?: string;
signal?: AbortSignal;
}): Promise<LlmResponse<T>> {
const {
mode,
model,
messages,
jsonSchema,
maxRetries,
retryDelayMs,
signal,
} = args;
const jobId = args.jobId;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
if (attempt > 0) {
logger.info("LLM retry attempt", {
jobId: jobId ?? "unknown",
attempt,
maxRetries,
});
await sleep(getRetryDelayMs(retryDelayMs, attempt));
}
const { url, headers, body } = this.strategy.buildRequest({
mode,
baseUrl: this.baseUrl,
apiKey: this.apiKey,
model,
messages,
jsonSchema,
});
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal,
});
if (!response.ok) {
const errorBody = await response.text().catch(() => "No error body");
const parsedError = parseErrorMessage(errorBody);
const detail = parsedError ? ` - ${truncate(parsedError, 400)}` : "";
const err = new Error(
`LLM API error: ${response.status}${detail}`,
) as LlmApiError;
err.status = response.status;
err.body = truncate(errorBody, 600);
throw err;
}
const data = await response.json();
const content = this.strategy.extractText(data);
if (!content) {
throw new Error("No content in response");
}
const parsed = parseJsonContent<T>(content, jobId);
return { success: true, data: parsed };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const status = (error as LlmApiError).status;
const body = (error as LlmApiError).body;
if (
this.strategy.isCapabilityError({
mode,
status,
body,
})
) {
return { success: false, error: `CAPABILITY:${message}` };
}
if (attempt < maxRetries && shouldRetryAttempt({ message, status })) {
logger.warn("LLM attempt failed, retrying", {
jobId: jobId ?? "unknown",
attempt: attempt + 1,
maxRetries,
status: status ?? "no-status",
message,
});
continue;
}
return { success: false, error: message };
}
}
return { success: false, error: "All retry attempts failed" };
}
}
function normalizeProvider(
raw: string | null,
baseUrl: string | null,
): LlmProvider {
const normalized = raw?.trim().toLowerCase();
if (normalized === "openai_compatible") {
if (
baseUrl?.includes("localhost:1234") ||
baseUrl?.includes("127.0.0.1:1234")
) {
return "lmstudio";
}
return "openai";
}
if (normalized === "openai") return "openai";
if (normalized === "gemini") return "gemini";
if (normalized === "lmstudio") return "lmstudio";
if (normalized === "ollama") return "ollama";
if (normalized && normalized !== "openrouter") {
logger.warn("Unknown LLM provider, defaulting to openrouter", {
normalized,
});
}
return "openrouter";
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}