Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions electron-builder.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ files:
- node_modules/@img/**/*
- node_modules/better-sqlite3/**/*
- node_modules/@anthropic-ai/sdk/**/*
# @google/genai is external in the Electron main bundle (see vite.config.ts).
- node_modules/@google/genai/**/*
- node_modules/google-auth-library/**/*
- node_modules/gaxios/**/*
- node_modules/gcp-metadata/**/*
- node_modules/google-logging-utils/**/*
- node_modules/protobufjs/**/*
- node_modules/p-retry/**/*
- node_modules/retry/**/*
- node_modules/@larksuiteoapi/**/*
- node_modules/openai/**/*
- node_modules/@modelcontextprotocol/**/*
Expand Down
23 changes: 23 additions & 0 deletions src/main/claude/claude-sdk-one-shot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import {
resolvePiRouteProtocol,
resolveSyntheticPiModelFallback,
} from './pi-model-resolution';
import {
isGeminiSdkProbeUnavailableError,
probeGeminiRelayGenerateContent,
} from '../config/gemini-relay-probe';

const NETWORK_ERROR_RE =
/enotfound|econnrefused|etimedout|eai_again|enetunreach|timed?\s*out|timeout|abort|network\s*error/i;
Expand Down Expand Up @@ -354,6 +358,25 @@ export async function probeWithClaudeSdk(
} catch (error) {
const details = error instanceof Error ? error.message : String(error);
const elapsed = Date.now() - probeStart;
const route = resolvePiRouteProtocol(input.provider, probeConfig.customProtocol);
const relayBaseUrl = probeConfig.baseUrl?.trim();
if (
route === 'gemini' &&
relayBaseUrl &&
probeConfig.apiKey &&
probeConfig.model &&
isGeminiSdkProbeUnavailableError(details)
) {
logWarn('[Probe] Gemini SDK probe failed, trying HTTP generateContent:', details);
const httpResult = await probeGeminiRelayGenerateContent({
baseUrl: relayBaseUrl,
apiKey: probeConfig.apiKey,
model: probeConfig.model,
});
if (httpResult.ok) {
return httpResult;
}
}
return mapPiAiError(details, elapsed, input.provider);
}
}
Expand Down
29 changes: 26 additions & 3 deletions src/main/config/api-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ import type {
import { log, logWarn } from '../utils/logger';
import { probeWithClaudeSdk } from '../claude/claude-sdk-one-shot';
import { fetchOllamaModelIndex } from './ollama-api';
import {
fetchGeminiRelayModelMetadata,
isGeminiSdkProbeUnavailableError,
} from './gemini-relay-probe';
import { createGoogleGenAIClient } from './google-genai-loader';

const STEP_NAMES: DiagnosticStepName[] = ['dns', 'tcp', 'tls', 'auth', 'model'];
const TCP_TIMEOUT_MS = 5000;
Expand Down Expand Up @@ -373,16 +378,34 @@ async function stepAuth(input: DiagnosticInput, step: DiagnosticStep): Promise<v
return;
}

const clientBaseUrl = resolveClientBaseUrl(input);
try {
const { GoogleGenAI } = (await import('@google/genai')) as typeof import('@google/genai');
const clientBaseUrl = resolveClientBaseUrl(input);
const httpOptions = { ...(clientBaseUrl ? { baseUrl: clientBaseUrl } : {}), timeout: 15000 };
const client = new GoogleGenAI({ apiKey, httpOptions });
const client = createGoogleGenAIClient({ apiKey, httpOptions });
const modelToCheck = input.model?.trim() || 'gemini-3-flash-preview';
await client.models.get({ model: modelToCheck });
step.status = 'ok';
} catch (err) {
const e = getApiErrorInfo(err);
if (clientBaseUrl && isGeminiSdkProbeUnavailableError(e.message)) {
try {
await fetchGeminiRelayModelMetadata({
baseUrl: clientBaseUrl,
apiKey,
model: input.model?.trim() || 'gemini-3-flash-preview',
});
step.status = 'ok';
step.fix = 'gemini_auth_probe_http_fallback';
step.latencyMs = Date.now() - start;
return;
} catch (httpErr) {
const httpInfo = getApiErrorInfo(httpErr);
if (!isLikelyAuthFailure(httpInfo)) {
e.message = httpInfo.message;
e.status = httpInfo.status;
}
}
}
if (shouldContinueAfterGeminiAuthProbeError(e)) {
// Some SDK/proxy combinations do not support the lightweight models.get
// endpoint. Continue to the live model probe, which exercises inference.
Expand Down
137 changes: 137 additions & 0 deletions src/main/config/gemini-relay-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { ApiTestResult } from '../../renderer/types';

const AUTH_ERROR_RE =
/authentication[_\s-]?failed|\bunauthorized\b|invalid[_\s-]?api[_\s-]?key|api[_\s-]?key[_\s-]?invalid|api[_\s]+key[_\s]+not[_\s]+valid|\bforbidden\b|permission[_\s-]?denied|\b401\b|\b403\b/i;
const RATE_LIMIT_RE = /rate[_\s-]?limit|too\s+many\s+requests|429/i;
const SERVER_ERROR_RE = /server[_\s-]?error|internal\s+server\s+error|\b5\d\d\b/i;
const NETWORK_ERROR_RE =
/enotfound|econnrefused|etimedout|eai_again|enetunreach|timed?\s*out|timeout|abort|network\s*error|fetch failed/i;

export function isGeminiSdkProbeUnavailableError(message: string): boolean {
return /models\.get|reading ['"]get['"]|reading get/i.test(message);
}

function stripTrailingSlash(url: string): string {
return url.replace(/\/+$/, '');
}

/** POST …/models/{id}:generateContent (pi-ai clears apiVersion when baseUrl is set). */
export function geminiRelayGenerateBaseUrl(baseUrl: string): string {
return stripTrailingSlash(baseUrl.trim());
}

/** GET …/v1beta/models/{id} (matches @google/genai default path layout). */
export function geminiRelayMetadataBaseUrl(baseUrl: string): string {
const base = stripTrailingSlash(baseUrl.trim());
if (base.endsWith('/v1beta')) {
return base;
}
if (base.endsWith('/gemini')) {
return `${base}/v1beta`;
}
return `${base}/v1beta`;
}

export function geminiModelId(model: string): string {
return model.trim().replace(/^models\//, '');
}

function mapHttpProbeError(status: number, body: string, latencyMs: number): ApiTestResult {
const details = body.trim() || `HTTP ${status}`;
const lowered = details.toLowerCase();

if (status === 401 || status === 403 || AUTH_ERROR_RE.test(lowered)) {
return { ok: false, latencyMs, status, errorType: 'unauthorized', details };
}
if (status === 404) {
return { ok: false, latencyMs, status, errorType: 'not_found', details };
}
if (status === 429 || RATE_LIMIT_RE.test(lowered)) {
return { ok: false, latencyMs, status, errorType: 'rate_limited', details };
}
if (status >= 500 || SERVER_ERROR_RE.test(lowered)) {
return { ok: false, latencyMs, status, errorType: 'server_error', details };
}
return { ok: false, latencyMs, status, errorType: 'unknown', details };
}

function mapThrownProbeError(error: unknown, latencyMs: number): ApiTestResult {
const details = error instanceof Error ? error.message : String(error);
const lowered = details.toLowerCase();
if (NETWORK_ERROR_RE.test(lowered)) {
return { ok: false, latencyMs, errorType: 'network_error', details };
}
if (AUTH_ERROR_RE.test(lowered)) {
return { ok: false, latencyMs, errorType: 'unauthorized', details };
}
return { ok: false, latencyMs, errorType: 'unknown', details };
}

/** Lightweight auth check: GET /v1beta/models/{model} against a Gemini-compatible relay (e.g. one-api). */
export async function fetchGeminiRelayModelMetadata(input: {
baseUrl: string;
apiKey: string;
model: string;
timeoutMs?: number;
}): Promise<void> {
const modelId = geminiModelId(input.model);
const url = `${geminiRelayMetadataBaseUrl(input.baseUrl)}/models/${encodeURIComponent(modelId)}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), input.timeoutMs ?? 15000);
try {
const response = await fetch(url, {
method: 'GET',
headers: { 'x-goog-api-key': input.apiKey },
signal: controller.signal,
});
if (!response.ok) {
const body = await response.text();
const err = new Error(body || `HTTP ${response.status}`) as Error & { status?: number };
err.status = response.status;
throw err;
}
} finally {
clearTimeout(timeout);
}
}

/** Live inference probe via native Gemini generateContent (works with one-api /gemini base). */
export async function probeGeminiRelayGenerateContent(input: {
baseUrl: string;
apiKey: string;
model: string;
timeoutMs?: number;
}): Promise<ApiTestResult> {
const started = Date.now();
const modelId = geminiModelId(input.model);
const url = `${geminiRelayGenerateBaseUrl(input.baseUrl)}/models/${encodeURIComponent(modelId)}:generateContent`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), input.timeoutMs ?? 30000);

try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': input.apiKey,
},
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: 'ping' }] }],
generationConfig: { maxOutputTokens: 16 },
}),
signal: controller.signal,
});
const body = await response.text();
const latencyMs = Date.now() - started;

if (!response.ok) {
return mapHttpProbeError(response.status, body, latencyMs);
}

return { ok: true, latencyMs, status: response.status };
} catch (error) {
return mapThrownProbeError(error, Date.now() - started);
} finally {
clearTimeout(timeout);
}
}
43 changes: 43 additions & 0 deletions src/main/config/google-genai-loader.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Load {@link GoogleGenAI} from the installed Node package at runtime.
*
* Electron main must not bundle @google/genai into the pi-ai mega-chunk (breaks
* `client.models.get`). Vite marks the package as external; this loader uses
* createRequire so the CJS build resolves `dist/node/index.cjs`.
*/
import { createRequire } from 'node:module';
type GoogleGenAIModule = typeof import('@google/genai');

const nodeRequire = createRequire(import.meta.url);

let GoogleGenAIClass: (typeof import('@google/genai'))['GoogleGenAI'] | undefined;

/**
* Returns the GoogleGenAI constructor from node_modules (never from a Vite chunk).
*/
export function loadGoogleGenAI(): GoogleGenAIModule['GoogleGenAI'] {
if (GoogleGenAIClass) {
return GoogleGenAIClass;
}

const mod = nodeRequire('@google/genai') as GoogleGenAIModule & { default?: GoogleGenAIModule };
const ctor =
mod.GoogleGenAI ??
(mod.default && 'GoogleGenAI' in mod.default ? mod.default.GoogleGenAI : undefined);

if (!ctor) {
throw new Error('@google/genai: GoogleGenAI export not found (check node_modules install)');
}

GoogleGenAIClass = ctor;
return GoogleGenAIClass;
}

export type GoogleGenAIClient = InstanceType<GoogleGenAIModule['GoogleGenAI']>;

export function createGoogleGenAIClient(
options: ConstructorParameters<GoogleGenAIModule['GoogleGenAI']>[0]
): GoogleGenAIClient {
const GoogleGenAI = loadGoogleGenAI();
return new GoogleGenAI(options);
}
5 changes: 1 addition & 4 deletions src/main/mcp/mcp-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,6 @@ async function raceWithTimeout<T>(
}
}


function getTrustedWindowsNpxDirectories(
env: Record<string, string | undefined> = process.env
): string[] {
Expand Down Expand Up @@ -1455,9 +1454,7 @@ export class MCPManager {
const usedToolNames = new Set<string>();
const tools = sortedTools.map((tool) => {
const originalToolName =
typeof tool.name === 'string' && tool.name.trim().length > 0
? tool.name
: 'tool';
typeof tool.name === 'string' && tool.name.trim().length > 0 ? tool.name : 'tool';
const sanitizedToolName = sanitizeMcpToolSegment(originalToolName, 'tool');
const prefixedName = createUniqueMcpToolName(
`mcp__${serverKey}__${sanitizedToolName}`,
Expand Down
Loading
Loading