mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
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:
@@ -620,7 +620,7 @@ fn merge_claude_config(custom_config: &JsonValue, common_snippet: &str) -> Merge
|
|||||||
// Return warning without logging - caller will log if needed
|
// Return warning without logging - caller will log if needed
|
||||||
return MergeResult {
|
return MergeResult {
|
||||||
config: custom_config.clone(),
|
config: custom_config.clone(),
|
||||||
warning: Some(format!("Claude common config parse error: {e}")),
|
warning: Some(format!("COMMON_CONFIG_PARSE_ERROR: {e}")),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -642,7 +642,7 @@ fn merge_codex_config(custom_config: &JsonValue, common_snippet: &str) -> MergeR
|
|||||||
compute_final_toml_config_str(config_str, common_snippet, true);
|
compute_final_toml_config_str(config_str, common_snippet, true);
|
||||||
if let Some(e) = error {
|
if let Some(e) = error {
|
||||||
// Return warning without logging - caller will log if needed
|
// Return warning without logging - caller will log if needed
|
||||||
warning = Some(format!("Codex TOML merge warning: {e}"));
|
warning = Some(format!("CODEX_TOML_MERGE_ERROR: {e}"));
|
||||||
}
|
}
|
||||||
obj.insert("config".to_string(), JsonValue::String(merged_toml));
|
obj.insert("config".to_string(), JsonValue::String(merged_toml));
|
||||||
}
|
}
|
||||||
@@ -656,44 +656,25 @@ fn merge_codex_config(custom_config: &JsonValue, common_snippet: &str) -> MergeR
|
|||||||
|
|
||||||
/// Merge Gemini config (JSON format for env field).
|
/// Merge Gemini config (JSON format for env field).
|
||||||
///
|
///
|
||||||
/// Gemini common config can be stored in two formats:
|
/// Gemini common config can be stored in three formats:
|
||||||
/// - Wrapped: `{"env": {"KEY": "VALUE", ...}}` (matches provider settings_config structure)
|
/// - ENV format: KEY=VALUE lines (one per line)
|
||||||
/// - Flat: `{"KEY": "VALUE", ...}` (simpler format used by frontend)
|
/// - Flat JSON: `{"KEY": "VALUE", ...}`
|
||||||
|
/// - Wrapped JSON: `{"env": {"KEY": "VALUE", ...}}`
|
||||||
///
|
///
|
||||||
/// This function supports both formats for backward compatibility.
|
/// This function supports all formats for backward compatibility.
|
||||||
fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
|
fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
|
||||||
let common_value: JsonValue = match serde_json::from_str(common_snippet) {
|
// Parse common config (try JSON first, then ENV format)
|
||||||
Ok(v) => v,
|
let common_env = parse_gemini_common_snippet(common_snippet);
|
||||||
Err(e) => {
|
|
||||||
// Return warning without logging - caller will log if needed
|
if common_env.is_empty() {
|
||||||
return MergeResult {
|
return MergeResult {
|
||||||
config: custom_config.clone(),
|
config: custom_config.clone(),
|
||||||
warning: Some(format!("Gemini common config parse error: {e}")),
|
warning: None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let mut merged_config = custom_config.clone();
|
let mut merged_config = custom_config.clone();
|
||||||
|
|
||||||
// Get the common env object (support both wrapped and flat formats)
|
|
||||||
let common_env = match common_value.as_object() {
|
|
||||||
Some(obj) => {
|
|
||||||
// Check if it's wrapped format {"env": {...}}
|
|
||||||
if let Some(env_value) = obj.get("env").and_then(|v| v.as_object()) {
|
|
||||||
env_value.clone()
|
|
||||||
} else {
|
|
||||||
// Flat format {"KEY": "VALUE", ...}
|
|
||||||
obj.clone()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
return MergeResult {
|
|
||||||
config: custom_config.clone(),
|
|
||||||
warning: Some("COMMON_CONFIG_NOT_OBJECT".to_string()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Merge only the env field
|
// Merge only the env field
|
||||||
// If custom config has env, merge common into it; otherwise initialize with common env
|
// If custom config has env, merge common into it; otherwise initialize with common env
|
||||||
if let Some(merged_obj) = merged_config.as_object_mut() {
|
if let Some(merged_obj) = merged_config.as_object_mut() {
|
||||||
@@ -718,6 +699,65 @@ fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> Merge
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse Gemini common config snippet supporting multiple formats.
|
||||||
|
///
|
||||||
|
/// Formats supported:
|
||||||
|
/// - ENV format: KEY=VALUE lines
|
||||||
|
/// - Flat JSON: {"KEY": "VALUE", ...}
|
||||||
|
/// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
|
||||||
|
fn parse_gemini_common_snippet(snippet: &str) -> serde_json::Map<String, JsonValue> {
|
||||||
|
let trimmed = snippet.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return serde_json::Map::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try JSON first
|
||||||
|
if let Ok(parsed) = serde_json::from_str::<JsonValue>(trimmed) {
|
||||||
|
if let Some(obj) = parsed.as_object() {
|
||||||
|
// Check if it's wrapped format {"env": {...}}
|
||||||
|
if let Some(env_value) = obj.get("env").and_then(|v| v.as_object()) {
|
||||||
|
return env_value.clone();
|
||||||
|
}
|
||||||
|
// Flat format
|
||||||
|
return obj.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse as ENV format (KEY=VALUE lines)
|
||||||
|
let mut result = serde_json::Map::new();
|
||||||
|
for line in trimmed.lines() {
|
||||||
|
let line_trimmed = line.trim();
|
||||||
|
if line_trimmed.is_empty() || line_trimmed.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(equal_index) = line_trimmed.find('=') {
|
||||||
|
let key = line_trimmed[..equal_index].trim();
|
||||||
|
let raw_value = line_trimmed[equal_index + 1..].trim();
|
||||||
|
// Strip surrounding quotes (single or double) from value
|
||||||
|
// e.g., KEY="value" or KEY='value' -> value
|
||||||
|
let value = strip_env_quotes(raw_value);
|
||||||
|
if !key.is_empty() {
|
||||||
|
result.insert(key.to_string(), JsonValue::String(value.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip surrounding quotes from ENV value.
|
||||||
|
/// Supports both single and double quotes: "value" -> value, 'value' -> value
|
||||||
|
fn strip_env_quotes(s: &str) -> &str {
|
||||||
|
let bytes = s.as_bytes();
|
||||||
|
if bytes.len() >= 2 {
|
||||||
|
let first = bytes[0];
|
||||||
|
let last = bytes[bytes.len() - 1];
|
||||||
|
if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
|
||||||
|
return &s[1..s.len() - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Unit Tests
|
// Unit Tests
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -167,15 +167,18 @@ fn write_live_snapshot_internal(
|
|||||||
}
|
}
|
||||||
AppType::Codex => {
|
AppType::Codex => {
|
||||||
let obj = config_to_write.as_object().ok_or_else(|| {
|
let obj = config_to_write.as_object().ok_or_else(|| {
|
||||||
AppError::Config("Codex provider settings_config must be a JSON object".to_string())
|
AppError::Config(
|
||||||
|
"CODEX_CONFIG_NOT_OBJECT: settings_config must be a JSON object".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
let auth = obj.get("auth").ok_or_else(|| {
|
let auth = obj.get("auth").ok_or_else(|| {
|
||||||
AppError::Config("Codex provider settings_config missing 'auth' field".to_string())
|
AppError::Config(
|
||||||
|
"CODEX_CONFIG_MISSING_AUTH: settings_config missing 'auth' field".to_string(),
|
||||||
|
)
|
||||||
})?;
|
})?;
|
||||||
let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
|
let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||||
AppError::Config(
|
AppError::Config(
|
||||||
"Codex provider settings_config missing 'config' field or not a string"
|
"CODEX_CONFIG_MISSING_CONFIG: settings_config missing 'config' field or not a string".to_string(),
|
||||||
.to_string(),
|
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Save } from "lucide-react";
|
import { Save } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
import { providersApi, vscodeApi, configApi, type AppId } from "@/lib/api";
|
import { providersApi, vscodeApi, configApi, type AppId } from "@/lib/api";
|
||||||
import { extractDifference, isPlainObject } from "@/utils/configMerge";
|
import { extractDifference, isPlainObject } from "@/utils/configMerge";
|
||||||
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
|
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
|
||||||
|
import { parseGeminiCommonConfigSnippet } from "@/utils/providerConfigUtils";
|
||||||
|
|
||||||
interface EditProviderDialogProps {
|
interface EditProviderDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -114,42 +116,46 @@ export function EditProviderDialog({
|
|||||||
setLiveSettings(live);
|
setLiveSettings(live);
|
||||||
}
|
}
|
||||||
} else if (appId === "gemini") {
|
} 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", ...}
|
// - Flat JSON: {"KEY": "VALUE", ...}
|
||||||
// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
|
// - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
|
||||||
const liveEnv =
|
const liveEnv =
|
||||||
(live as { env?: Record<string, string> }).env ?? {};
|
(live as { env?: Record<string, string> }).env ?? {};
|
||||||
// Parse common config as JSON with format detection
|
|
||||||
const parsedSnippet = JSON.parse(
|
// Use shared parser with validation
|
||||||
commonSnippet.trim(),
|
const parseResult = parseGeminiCommonConfigSnippet(
|
||||||
) as Record<string, unknown>;
|
commonSnippet,
|
||||||
// Check if wrapped format {"env": {...}}
|
{ strictForbiddenKeys: false },
|
||||||
const commonEnvRaw =
|
);
|
||||||
parsedSnippet.env &&
|
|
||||||
typeof parsedSnippet.env === "object"
|
if (parseResult.error) {
|
||||||
? (parsedSnippet.env as Record<string, unknown>)
|
console.warn(
|
||||||
: parsedSnippet;
|
"[EditProviderDialog] Gemini common config parse error:",
|
||||||
// Convert to string record, filtering out non-string values
|
parseResult.error,
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
setLiveSettings({
|
|
||||||
...live,
|
|
||||||
env: customConfig,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setLiveSettings(live);
|
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 {
|
} else {
|
||||||
// Claude: 处理 JSON 格式
|
// Claude: 处理 JSON 格式
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
|||||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||||
import { extractDifference, isPlainObject } from "@/utils/configMerge";
|
import { extractDifference, isPlainObject } from "@/utils/configMerge";
|
||||||
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
|
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
|
||||||
|
import { parseGeminiCommonConfigSnippet } from "@/utils/providerConfigUtils";
|
||||||
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
||||||
import CodexConfigEditor from "./CodexConfigEditor";
|
import CodexConfigEditor from "./CodexConfigEditor";
|
||||||
import { CommonConfigEditor } from "./CommonConfigEditor";
|
import { CommonConfigEditor } from "./CommonConfigEditor";
|
||||||
@@ -68,33 +69,33 @@ import {
|
|||||||
import { useProvidersQuery } from "@/lib/query/queries";
|
import { useProvidersQuery } from "@/lib/query/queries";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse Gemini common config snippet.
|
* Parse Gemini common config snippet for difference extraction.
|
||||||
* Supports two formats:
|
* 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", ...}
|
* - Flat JSON: {"KEY": "VALUE", ...}
|
||||||
* - Wrapped JSON: {"env": {"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> {
|
function parseGeminiCommonConfig(snippet: string): {
|
||||||
try {
|
env: Record<string, string>;
|
||||||
const parsed = JSON.parse(snippet);
|
warning?: string;
|
||||||
if (!parsed || typeof parsed !== "object") {
|
} {
|
||||||
return {};
|
const result = parseGeminiCommonConfigSnippet(snippet, {
|
||||||
}
|
strictForbiddenKeys: false, // Don't fail, just filter
|
||||||
// Check if it's wrapped format {"env": {...}}
|
});
|
||||||
const envObj =
|
|
||||||
parsed.env && typeof parsed.env === "object" ? parsed.env : parsed;
|
if (result.error) {
|
||||||
// Convert to string record
|
console.warn(
|
||||||
const result: Record<string, string> = {};
|
"[ProviderForm] Gemini common config parse error:",
|
||||||
for (const [key, value] of Object.entries(envObj)) {
|
result.error,
|
||||||
if (typeof value === "string") {
|
);
|
||||||
result[key] = value;
|
return { env: {} };
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { env: result.env, warning: result.warning };
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
|
const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2);
|
||||||
@@ -973,9 +974,13 @@ export function ProviderForm({
|
|||||||
if (useGeminiCommonConfigFlag && geminiCommonConfigSnippet.trim()) {
|
if (useGeminiCommonConfigFlag && geminiCommonConfigSnippet.trim()) {
|
||||||
// Parse common config as JSON (flat {"KEY": "VALUE"} format)
|
// Parse common config as JSON (flat {"KEY": "VALUE"} format)
|
||||||
// Note: geminiCommonConfigSnippet is stored as JSON by useGeminiCommonConfig hook
|
// Note: geminiCommonConfigSnippet is stored as JSON by useGeminiCommonConfig hook
|
||||||
const commonEnvObj = parseGeminiCommonConfig(
|
const { env: commonEnvObj, warning } = parseGeminiCommonConfig(
|
||||||
geminiCommonConfigSnippet.trim(),
|
geminiCommonConfigSnippet.trim(),
|
||||||
);
|
);
|
||||||
|
// Show warning toast if keys were filtered
|
||||||
|
if (warning) {
|
||||||
|
toast.warning(warning);
|
||||||
|
}
|
||||||
if (isPlainObject(envObj) && isPlainObject(commonEnvObj)) {
|
if (isPlainObject(envObj) && isPlainObject(commonEnvObj)) {
|
||||||
const { customConfig } = extractDifference(envObj, commonEnvObj);
|
const { customConfig } = extractDifference(envObj, commonEnvObj);
|
||||||
// Convert to string record with type guard to avoid type assertion issues
|
// 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 { toast } from "sonner";
|
||||||
import { configApi } from "@/lib/api";
|
import { configApi } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
GEMINI_COMMON_ENV_FORBIDDEN_KEYS,
|
parseGeminiCommonConfigSnippet,
|
||||||
type GeminiForbiddenEnvKey,
|
GEMINI_CONFIG_ERROR_CODES,
|
||||||
} from "@/utils/providerConfigUtils";
|
} from "@/utils/providerConfigUtils";
|
||||||
import {
|
import { computeFinalConfig, extractDifference } from "@/utils/configMerge";
|
||||||
computeFinalConfig,
|
|
||||||
extractDifference,
|
|
||||||
isPlainObject,
|
|
||||||
} from "@/utils/configMerge";
|
|
||||||
import type { ProviderMeta } from "@/types";
|
import type { ProviderMeta } from "@/types";
|
||||||
|
|
||||||
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
|
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
|
||||||
@@ -131,54 +127,38 @@ export function useGeminiCommonConfig({
|
|||||||
hasInitializedEditMode.current = false;
|
hasInitializedEditMode.current = false;
|
||||||
}, [selectedPresetId]);
|
}, [selectedPresetId]);
|
||||||
|
|
||||||
// 解析通用配置片段
|
// 解析通用配置片段 - 使用共享解析器
|
||||||
|
// 支持三种格式: ENV (KEY=VALUE), 扁平 JSON, 包裹 JSON {"env":{...}}
|
||||||
const parseSnippetEnv = useCallback(
|
const parseSnippetEnv = useCallback(
|
||||||
(
|
(
|
||||||
snippetString: string,
|
snippetString: string,
|
||||||
): { env: Record<string, string>; error?: string } => {
|
): { env: Record<string, string>; error?: string } => {
|
||||||
const trimmed = snippetString.trim();
|
const result = parseGeminiCommonConfigSnippet(snippetString, {
|
||||||
if (!trimmed) {
|
strictForbiddenKeys: true,
|
||||||
return { env: {} };
|
});
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: unknown;
|
if (result.error) {
|
||||||
try {
|
// Map error codes to i18n keys
|
||||||
parsed = JSON.parse(trimmed);
|
if (result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) {
|
||||||
} catch {
|
const keys = result.error.split(": ")[1] ?? result.error;
|
||||||
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
|
return {
|
||||||
}
|
env: {},
|
||||||
|
error: t("geminiConfig.commonConfigInvalidKeys", { keys }),
|
||||||
if (!isPlainObject(parsed)) {
|
};
|
||||||
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
|
}
|
||||||
}
|
if (
|
||||||
|
result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING)
|
||||||
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") {
|
|
||||||
return {
|
return {
|
||||||
env: {},
|
env: {},
|
||||||
error: t("geminiConfig.commonConfigInvalidValues"),
|
error: t("geminiConfig.commonConfigInvalidValues"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const normalized = value.trim();
|
// Generic format error (NOT_OBJECT, ENV_NOT_OBJECT, or parse failure)
|
||||||
if (!normalized) continue;
|
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
|
||||||
env[key] = normalized;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { env };
|
return { env: result.env };
|
||||||
},
|
},
|
||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
|
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
|
||||||
import { normalizeQuotes } from "@/utils/textNormalization";
|
import { normalizeQuotes } from "@/utils/textNormalization";
|
||||||
|
import { isPlainObject } from "@/utils/configMerge";
|
||||||
|
|
||||||
// Gemini 通用配置禁止的键(共享常量,供 hook 和同步逻辑复用)
|
// Gemini 通用配置禁止的键(共享常量,供 hook 和同步逻辑复用)
|
||||||
export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
||||||
@@ -11,10 +12,6 @@ export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
|||||||
export type GeminiForbiddenEnvKey =
|
export type GeminiForbiddenEnvKey =
|
||||||
(typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
|
(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 = (
|
const deepMerge = (
|
||||||
target: Record<string, any>,
|
target: Record<string, any>,
|
||||||
source: Record<string, any>,
|
source: Record<string, any>,
|
||||||
@@ -192,15 +189,20 @@ export const hasGeminiCommonConfigSnippet = (
|
|||||||
const parsed = JSON.parse(snippetString);
|
const parsed = JSON.parse(snippetString);
|
||||||
if (!isPlainObject(parsed)) return false;
|
if (!isPlainObject(parsed)) return false;
|
||||||
|
|
||||||
const entries = Object.entries(parsed).filter(([key, value]) => {
|
const entries = Object.entries(parsed).filter(
|
||||||
if (
|
(entry): entry is [string, string] => {
|
||||||
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey)
|
const [key, value] = entry;
|
||||||
) {
|
if (
|
||||||
return false;
|
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(
|
||||||
}
|
key as GeminiForbiddenEnvKey,
|
||||||
if (typeof value !== "string") return false;
|
)
|
||||||
return value.trim().length > 0;
|
) {
|
||||||
});
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof value !== "string") return false;
|
||||||
|
return value.trim().length > 0;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (entries.length === 0) return false;
|
if (entries.length === 0) return false;
|
||||||
|
|
||||||
@@ -1040,3 +1042,162 @@ export const setCodexModelName = (
|
|||||||
const lines = normalizedText.split("\n");
|
const lines = normalizedText.split("\n");
|
||||||
return `${replacementLine}\n${lines.join("\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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user