Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
30 changes: 27 additions & 3 deletions src/main/config/api-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
import { log, logWarn } from '../utils/logger';
import { probeWithClaudeSdk } from '../claude/claude-sdk-one-shot';
import { fetchOllamaModelIndex } from './ollama-api';
import {
fetchGeminiRelayModelMetadata,
isGeminiSdkProbeUnavailableError,
probeGeminiRelayGenerateContent,

Check failure on line 39 in src/main/config/api-diagnostics.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

'probeGeminiRelayGenerateContent' is declared but its value is never read.

Check warning on line 39 in src/main/config/api-diagnostics.ts

View workflow job for this annotation

GitHub Actions / Lint & Test

'probeGeminiRelayGenerateContent' is defined but never used
} 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 +379,34 @@
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);
}
34 changes: 34 additions & 0 deletions src/tests/config/google-genai-loader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';

const mockGoogleGenAI = vi.fn(function MockGoogleGenAI(
this: { apiKey: string },
opts: { apiKey: string }
) {
this.apiKey = opts.apiKey;
});

vi.mock('node:module', () => ({
createRequire: () => {
return (id: string) => {
if (id === '@google/genai') {
return { GoogleGenAI: mockGoogleGenAI };
}
throw new Error(`unexpected require: ${id}`);
};
},
}));

describe('google-genai-loader', () => {
it('loads GoogleGenAI from node_modules via createRequire', async () => {
vi.resetModules();
const { loadGoogleGenAI, createGoogleGenAIClient } =
await import('../../main/config/google-genai-loader');

const ctor = loadGoogleGenAI();
expect(ctor).toBe(mockGoogleGenAI);

const client = createGoogleGenAIClient({ apiKey: 'sk-test' });
expect(client).toBeDefined();
expect(mockGoogleGenAI).toHaveBeenCalledWith({ apiKey: 'sk-test' });
});
});
8 changes: 7 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import { resolve } from 'path';
import { builtinModules } from 'module';

// Node built-in modules must be external for Electron main process
const nodeBuiltins = builtinModules.flatMap(m => [m, `node:${m}`]);
const nodeBuiltins = builtinModules.flatMap((m) => [m, `node:${m}`]);

// Keep @google/genai out of the pi-ai bundle — bundled copy breaks models.get in Electron.
const googleGenaiExternals = ['@google/genai', /^@google\/genai\//];
const ignoredWatchPaths = [
'**/release/**',
'**/dist/**',
Expand Down Expand Up @@ -37,6 +40,7 @@ export default defineConfig({
// Externalize large CJS-compatible main-process dependencies
// NOTE: ESM-only packages (pi-coding-agent, pi-ai, electron-store, uuid)
// must stay bundled — CJS require() can't load them
...googleGenaiExternals,
'@anthropic-ai/sdk',
'@larksuiteoapi/node-sdk',
'openai',
Expand Down Expand Up @@ -78,6 +82,8 @@ export default defineConfig({
'@': resolve(__dirname, 'src'),
'@main': resolve(__dirname, 'src/main'),
'@renderer': resolve(__dirname, 'src/renderer'),
// Prefer Node build when tooling still resolves @google/genai during dev compile.
'@google/genai': resolve(__dirname, 'node_modules/@google/genai/dist/node/index.mjs'),
},
},
server: {
Expand Down
Loading