fix(config): improve Gemini common config parsing robustness

- Add ENV format quote stripping (KEY="value" -> value)
- Add toast warnings for filtered forbidden keys
- Unify error codes with GEMINI_CONFIG_ERROR_CODES constants
- Remove duplicate isPlainObject, use shared implementation
- Fix type guard for filter entries
This commit is contained in:
YoVinchen
2026-01-26 22:46:24 +08:00
parent 4e4a445922
commit 542d4635c4
6 changed files with 343 additions and 148 deletions
+36 -30
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
@@ -11,6 +12,7 @@ import {
import { providersApi, vscodeApi, configApi, type AppId } from "@/lib/api";
import { extractDifference, isPlainObject } from "@/utils/configMerge";
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
import { parseGeminiCommonConfigSnippet } from "@/utils/providerConfigUtils";
interface EditProviderDialogProps {
open: boolean;
@@ -114,42 +116,46 @@ export function EditProviderDialog({
setLiveSettings(live);
}
} else if (appId === "gemini") {
// Gemini: common config supports two formats:
// Gemini: common config supports three formats:
// - ENV format: KEY=VALUE lines
// - Flat JSON: {"KEY": "VALUE", ...}
// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
const liveEnv =
(live as { env?: Record<string, string> }).env ?? {};
// Parse common config as JSON with format detection
const parsedSnippet = JSON.parse(
commonSnippet.trim(),
) as Record<string, unknown>;
// Check if wrapped format {"env": {...}}
const commonEnvRaw =
parsedSnippet.env &&
typeof parsedSnippet.env === "object"
? (parsedSnippet.env as Record<string, unknown>)
: parsedSnippet;
// Convert to string record, filtering out non-string values
const commonEnvStrObj: Record<string, string> = {};
for (const [key, value] of Object.entries(commonEnvRaw)) {
if (typeof value === "string") {
commonEnvStrObj[key] = value;
}
}
if (
isPlainObject(liveEnv) &&
isPlainObject(commonEnvStrObj)
) {
const { customConfig } = extractDifference(
liveEnv,
commonEnvStrObj,
// Use shared parser with validation
const parseResult = parseGeminiCommonConfigSnippet(
commonSnippet,
{ strictForbiddenKeys: false },
);
if (parseResult.error) {
console.warn(
"[EditProviderDialog] Gemini common config parse error:",
parseResult.error,
);
setLiveSettings({
...live,
env: customConfig,
});
} else {
setLiveSettings(live);
} else {
// Show warning toast if keys were filtered
if (parseResult.warning) {
toast.warning(parseResult.warning);
}
if (
isPlainObject(liveEnv) &&
Object.keys(parseResult.env).length > 0
) {
const { customConfig } = extractDifference(
liveEnv,
parseResult.env,
);
setLiveSettings({
...live,
env: customConfig,
});
} else {
setLiveSettings(live);
}
}
} else {
// Claude: 处理 JSON 格式
+28 -23
View File
@@ -38,6 +38,7 @@ import { applyTemplateValues } from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
import { extractDifference, isPlainObject } from "@/utils/configMerge";
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
import { parseGeminiCommonConfigSnippet } from "@/utils/providerConfigUtils";
import { getCodexCustomTemplate } from "@/config/codexTemplates";
import CodexConfigEditor from "./CodexConfigEditor";
import { CommonConfigEditor } from "./CommonConfigEditor";
@@ -68,33 +69,33 @@ import {
import { useProvidersQuery } from "@/lib/query/queries";
/**
* Parse Gemini common config snippet.
* Supports two formats:
* Parse Gemini common config snippet for difference extraction.
* Uses shared parser with non-strict forbidden keys (filter instead of reject).
*
* Supports three formats:
* - ENV format: KEY=VALUE lines (one per line)
* - Flat JSON: {"KEY": "VALUE", ...}
* - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
*
* Returns empty object if parsing fails.
* Returns { env, warning } - caller should display warning via toast if present.
*/
function parseGeminiCommonConfig(snippet: string): Record<string, string> {
try {
const parsed = JSON.parse(snippet);
if (!parsed || typeof parsed !== "object") {
return {};
}
// Check if it's wrapped format {"env": {...}}
const envObj =
parsed.env && typeof parsed.env === "object" ? parsed.env : parsed;
// Convert to string record
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(envObj)) {
if (typeof value === "string") {
result[key] = value;
}
}
return result;
} catch {
return {};
function parseGeminiCommonConfig(snippet: string): {
env: Record<string, string>;
warning?: string;
} {
const result = parseGeminiCommonConfigSnippet(snippet, {
strictForbiddenKeys: false, // Don't fail, just filter
});
if (result.error) {
console.warn(
"[ProviderForm] Gemini common config parse error:",
result.error,
);
return { env: {} };
}
return { env: result.env, warning: result.warning };
}
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
@@ -973,9 +974,13 @@ export function ProviderForm({
if (useGeminiCommonConfigFlag && geminiCommonConfigSnippet.trim()) {
// Parse common config as JSON (flat {"KEY": "VALUE"} format)
// Note: geminiCommonConfigSnippet is stored as JSON by useGeminiCommonConfig hook
const commonEnvObj = parseGeminiCommonConfig(
const { env: commonEnvObj, warning } = parseGeminiCommonConfig(
geminiCommonConfigSnippet.trim(),
);
// Show warning toast if keys were filtered
if (warning) {
toast.warning(warning);
}
if (isPlainObject(envObj) && isPlainObject(commonEnvObj)) {
const { customConfig } = extractDifference(envObj, commonEnvObj);
// Convert to string record with type guard to avoid type assertion issues
@@ -3,14 +3,10 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { configApi } from "@/lib/api";
import {
GEMINI_COMMON_ENV_FORBIDDEN_KEYS,
type GeminiForbiddenEnvKey,
parseGeminiCommonConfigSnippet,
GEMINI_CONFIG_ERROR_CODES,
} from "@/utils/providerConfigUtils";
import {
computeFinalConfig,
extractDifference,
isPlainObject,
} from "@/utils/configMerge";
import { computeFinalConfig, extractDifference } from "@/utils/configMerge";
import type { ProviderMeta } from "@/types";
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
@@ -131,54 +127,38 @@ export function useGeminiCommonConfig({
hasInitializedEditMode.current = false;
}, [selectedPresetId]);
// 解析通用配置片段
// 解析通用配置片段 - 使用共享解析器
// 支持三种格式: ENV (KEY=VALUE), 扁平 JSON, 包裹 JSON {"env":{...}}
const parseSnippetEnv = useCallback(
(
snippetString: string,
): { env: Record<string, string>; error?: string } => {
const trimmed = snippetString.trim();
if (!trimmed) {
return { env: {} };
}
const result = parseGeminiCommonConfigSnippet(snippetString, {
strictForbiddenKeys: true,
});
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch {
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
}
if (!isPlainObject(parsed)) {
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
}
const keys = Object.keys(parsed);
const forbiddenKeys = keys.filter((key) =>
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),
);
if (forbiddenKeys.length > 0) {
return {
env: {},
error: t("geminiConfig.commonConfigInvalidKeys", {
keys: forbiddenKeys.join(", "),
}),
};
}
const env: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value !== "string") {
if (result.error) {
// Map error codes to i18n keys
if (result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) {
const keys = result.error.split(": ")[1] ?? result.error;
return {
env: {},
error: t("geminiConfig.commonConfigInvalidKeys", { keys }),
};
}
if (
result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING)
) {
return {
env: {},
error: t("geminiConfig.commonConfigInvalidValues"),
};
}
const normalized = value.trim();
if (!normalized) continue;
env[key] = normalized;
// Generic format error (NOT_OBJECT, ENV_NOT_OBJECT, or parse failure)
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
}
return { env };
return { env: result.env };
},
[t],
);
+174 -13
View File
@@ -2,6 +2,7 @@
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
import { normalizeQuotes } from "@/utils/textNormalization";
import { isPlainObject } from "@/utils/configMerge";
// Gemini 通用配置禁止的键(共享常量,供 hook 和同步逻辑复用)
export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
@@ -11,10 +12,6 @@ export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
export type GeminiForbiddenEnvKey =
(typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]";
};
const deepMerge = (
target: Record<string, any>,
source: Record<string, any>,
@@ -192,15 +189,20 @@ export const hasGeminiCommonConfigSnippet = (
const parsed = JSON.parse(snippetString);
if (!isPlainObject(parsed)) return false;
const entries = Object.entries(parsed).filter(([key, value]) => {
if (
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey)
) {
return false;
}
if (typeof value !== "string") return false;
return value.trim().length > 0;
});
const entries = Object.entries(parsed).filter(
(entry): entry is [string, string] => {
const [key, value] = entry;
if (
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(
key as GeminiForbiddenEnvKey,
)
) {
return false;
}
if (typeof value !== "string") return false;
return value.trim().length > 0;
},
);
if (entries.length === 0) return false;
@@ -1040,3 +1042,162 @@ export const setCodexModelName = (
const lines = normalizedText.split("\n");
return `${replacementLine}\n${lines.join("\n")}`;
};
// ============================================================================
// Gemini Common Config Parsing Utilities
// ============================================================================
/**
* Error codes for Gemini common config parsing.
* These codes are used for consistent error handling and i18n mapping.
*/
export const GEMINI_CONFIG_ERROR_CODES = {
NOT_OBJECT: "GEMINI_CONFIG_NOT_OBJECT",
ENV_NOT_OBJECT: "GEMINI_CONFIG_ENV_NOT_OBJECT",
VALUE_NOT_STRING: "GEMINI_CONFIG_VALUE_NOT_STRING",
FORBIDDEN_KEYS: "GEMINI_CONFIG_FORBIDDEN_KEYS",
} as const;
/**
* Result of parsing Gemini common config snippet
*/
export interface GeminiCommonConfigParseResult {
/** Parsed env key-value pairs (empty if invalid) */
env: Record<string, string>;
/** Error message if parsing/validation failed (starts with error code) */
error?: string;
/** Warning message (non-fatal, config still usable) */
warning?: string;
}
/**
* Parse Gemini common config snippet with full validation.
*
* Supports three formats:
* - ENV format: KEY=VALUE lines (one per line, # for comments)
* - Flat JSON: {"KEY": "VALUE", ...}
* - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
*
* Validation rules:
* - Forbidden keys (GOOGLE_GEMINI_BASE_URL, GEMINI_API_KEY) are rejected
* - Non-string values are rejected
* - Empty string values are filtered out
* - Arrays and non-plain objects are rejected
*
* @param snippet - The common config snippet string
* @param options - Optional configuration
* @returns Parse result with env, error, and warning
*/
export function parseGeminiCommonConfigSnippet(
snippet: string,
options?: {
/** If true, reject forbidden keys with error; otherwise filter them with warning */
strictForbiddenKeys?: boolean;
},
): GeminiCommonConfigParseResult {
const trimmed = snippet.trim();
if (!trimmed) {
return { env: {} };
}
const strictForbiddenKeys = options?.strictForbiddenKeys ?? true;
let rawEnv: Record<string, unknown> = {};
let isJson = false;
// Try JSON first
try {
const parsed = JSON.parse(trimmed);
// Must be a plain object (not array, null, etc.)
if (!isPlainObject(parsed)) {
return {
env: {},
error: `${GEMINI_CONFIG_ERROR_CODES.NOT_OBJECT}: must be a JSON object, not array or primitive`,
};
}
isJson = true;
// Check if wrapped format {"env": {...}}
if ("env" in parsed) {
const envField = parsed.env;
if (!isPlainObject(envField)) {
return {
env: {},
error: `${GEMINI_CONFIG_ERROR_CODES.ENV_NOT_OBJECT}: 'env' field must be a plain object`,
};
}
rawEnv = envField as Record<string, unknown>;
} else {
// Flat format
rawEnv = parsed as Record<string, unknown>;
}
} catch {
// Not JSON, parse as ENV format (KEY=VALUE lines)
isJson = false;
for (const line of trimmed.split("\n")) {
const lineTrimmed = line.trim();
if (!lineTrimmed || lineTrimmed.startsWith("#")) continue;
const equalIndex = lineTrimmed.indexOf("=");
if (equalIndex > 0) {
const key = lineTrimmed.substring(0, equalIndex).trim();
// Strip surrounding quotes (single or double) from value
// e.g., KEY="value" or KEY='value' -> value
const rawValue = lineTrimmed.substring(equalIndex + 1).trim();
const value = rawValue.replace(/^["'](.*)["']$/, "$1");
if (key) {
rawEnv[key] = value;
}
}
}
}
// Validate and filter entries
const env: Record<string, string> = {};
const warnings: string[] = [];
const forbiddenKeysFound: string[] = [];
for (const [key, value] of Object.entries(rawEnv)) {
// Check forbidden keys
if (
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey)
) {
forbiddenKeysFound.push(key);
continue;
}
// Must be string
if (typeof value !== "string") {
if (isJson) {
return {
env: {},
error: `${GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING}: value for '${key}' must be a string, got ${typeof value}`,
};
}
// For ENV format, skip non-strings silently (shouldn't happen)
continue;
}
// Filter empty strings
const trimmedValue = value.trim();
if (!trimmedValue) {
continue;
}
env[key] = trimmedValue;
}
// Handle forbidden keys
if (forbiddenKeysFound.length > 0) {
const msg = `${GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS}: ${forbiddenKeysFound.join(", ")}`;
if (strictForbiddenKeys) {
return { env: {}, error: msg };
}
warnings.push(msg);
}
return {
env,
warning: warnings.length > 0 ? warnings.join("; ") : undefined,
};
}