Compare commits

...

12 Commits

Author SHA1 Message Date
YoVinchen
831d2c962c Merge branch 'main' into feat/proxy-url-refactor-full-url-mode 2026-03-11 10:07:44 +08:00
Zhou Mengze
75b4ef2299 fix: interpolate proxy startup toast address and port (#1399)
Co-authored-by: 周梦泽 <mengze.zhou@dafeng-tech.com>
2026-03-10 21:02:13 +08:00
bigsong
fab9874b2c fix: align OpenClaw tool permission profiles with upstream schema (#1355)
* fix: align OpenClaw tool permission profiles with upstream schema

* fix: remove dead i18n keys and save-blocking validation

- Remove unused `profiles.*` nested i18n keys (dead code, ToolsPanel uses flat `profileMinimal` etc.)
- Remove `invalidProfile` i18n key no longer referenced
- Remove handleSave validation that blocked saving allow/deny when legacy profile exists
- Keep the profile destructuring cleanup from the original PR

---------

Co-authored-by: Your Name <your.email@example.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-03-10 17:53:44 +08:00
liuxxxu
84668e2307 feat(openClaw form): add input type selection for models Advanced Options / 为模型高级选项添加输入类型选择 (#1368)
* feat(openClaw form): add input type selection for models Advanced Options / 为模型高级选项添加输入类型选择

* fix(i18n): add missing openclaw.inputTypes key to all locales

The new inputTypes field in OpenClawFormFields used defaultValue fallback,
causing English and Japanese users to see Chinese text.

---------

Co-authored-by: xu.liu2 <xu.liu2@brgroup.com>
Co-authored-by: Jason <farion1231@gmail.com>
2026-03-10 16:53:40 +08:00
wavever
b4033fdd29 fix: add missing authHeader: true to Longcat provider preset (#1377)
Fixes issue where Longcat models were failing with 404 error due to missing
authHeader configuration. According to Longcat API documentation, this
setting is required for proper authentication.

Closes: #1376
2026-03-10 16:14:08 +08:00
YoVinchen
68da178811 Merge branch 'main' into feat/proxy-url-refactor-full-url-mode 2026-03-10 14:00:47 +08:00
YoVinchen
3b3d1cd0ba Merge branch 'refs/heads/main' into feat/proxy-url-refactor-full-url-mode
# Conflicts:
#	src-tauri/src/provider.rs
#	src-tauri/src/proxy/forwarder.rs
#	src-tauri/src/proxy/usage/parser.rs
#	src/components/providers/forms/ClaudeFormFields.tsx
#	src/components/providers/forms/ProviderForm.tsx
#	src/components/providers/forms/hooks/useApiKeyState.ts
#	src/i18n/locales/en.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/zh.json
#	src/types.ts
#	src/utils/providerConfigUtils.ts
2026-03-09 23:16:12 +08:00
YoVinchen
3fa3558bd4 Merge branch 'main' into feat/proxy-url-refactor-full-url-mode
# Conflicts:
#	src-tauri/src/provider.rs
#	src-tauri/src/services/stream_check.rs
#	src/components/providers/forms/ClaudeFormFields.tsx
#	src/components/providers/forms/ProviderForm.tsx
#	src/types.ts
2026-03-09 09:26:24 +08:00
YoVinchen
788c221d39 Merge branch 'main' into feat/proxy-url-refactor-full-url-mode
# Conflicts:
#	src/components/providers/forms/ProviderForm.tsx
2026-02-27 10:44:34 +08:00
YoVinchen
a86f22422b style: fix clippy warnings and prettier formatting
- Inline format args in openclaw_config.rs, webdav_sync.rs, archive.rs
- Replace map_or(false, ..) with is_some_and(..) in live.rs
- Fix prettier line-break formatting in claudeProviderPresets.ts
2026-02-25 22:15:49 +08:00
YoVinchen
6427ab2128 feat(proxy): add isFullUrl toggle for full API endpoint mode
- provider.rs/types.ts: add is_full_url field to ProviderMeta
- forwarder.rs: when isFullUrl is set, use base_url directly instead of
  appending endpoint path; also handle query passthrough and strip
  beta=true when transforming to /v1/chat/completions
- EndpointField.tsx: add Link2 icon toggle button for full URL mode
- ClaudeFormFields.tsx: pass through isFullUrl/onFullUrlChange props
- ProviderForm.tsx: manage localIsFullUrl state, persist to meta on save
- useProviderActions.ts: block switching to isFullUrl or openai_chat
  providers when proxy is not running, show warning toast
- App.tsx: pass isProxyRunning to useProviderActions
- i18n: add fullUrlEnabled/fullUrlDisabled/fullUrlHint and
  proxyRequiredForSwitch translations for zh/en/ja
2026-02-25 22:11:52 +08:00
YoVinchen
87b08ce242 fix(proxy): remove hardcoded ?beta=true injection, passthrough client query params
- handlers.rs: add `uri` extractor to capture client's full path+query
- claude.rs: remove ?beta=true injection from build_url(), update tests
- stream_check.rs: remove ?beta=true from health check URL

The proxy no longer injects ?beta=true into API URLs. Instead, query
parameters sent by the Claude Code client are transparently passed through.
This fixes duplicate ?beta=true issues and aligns with upstream behavior
where the client already sends this parameter.
2026-02-25 22:03:17 +08:00
24 changed files with 412 additions and 122 deletions

View File

@@ -242,9 +242,14 @@ pub struct ProviderMeta {
/// - "openai_responses": OpenAI Responses API 格式,需要转换
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
pub api_format: Option<String>,
/// Claude 认证字段名("ANTHROPIC_AUTH_TOKEN" 或 "ANTHROPIC_API_KEY"
/// Claude 认证字段名(仅 Claude 供应商使用
/// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
/// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
pub api_key_field: Option<String>,
/// 是否将 base_url 视为完整 API 端点(不拼接 endpoint 路径)
#[serde(rename = "isFullUrl", skip_serializing_if = "Option::is_none")]
pub is_full_url: Option<bool>,
/// Prompt cache key for OpenAI-compatible endpoints.
/// When set, injected into converted requests to improve cache hit rate.
/// If not set, provider ID is used automatically during format conversion.

View File

@@ -792,21 +792,61 @@ impl RequestForwarder {
// 检查是否需要格式转换
let needs_transform = adapter.needs_transform(provider);
let effective_endpoint =
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
// 根据 api_format 选择目标端点
let api_format = super::providers::get_claude_api_format(provider);
if api_format == "openai_responses" {
"/v1/responses"
// 检查 isFullUrl 模式:直接使用 base_url 作为完整 API 端点
let is_full_url = provider
.meta
.as_ref()
.and_then(|m| m.is_full_url)
.unwrap_or(false);
let url = if is_full_url {
// 全链接模式:直接使用 base_url将客户端 query 追加
let query = endpoint.split_once('?').map(|(_, q)| q);
match query {
Some(q) if !q.is_empty() => {
if base_url.contains('?') {
format!("{base_url}&{q}")
} else {
format!("{base_url}?{q}")
}
}
_ => base_url.clone(),
}
} else {
// 正常模式endpoint 可能带 query string需拆分路径和参数
let effective_endpoint: String = if needs_transform && adapter.name() == "Claude" {
let (path, query) = match endpoint.split_once('?') {
Some((p, q)) => (p, Some(q)),
None => (endpoint, None),
};
if path == "/v1/messages" || path == "/claude/v1/messages" {
// 转换到 OpenAI 兼容端点时剥离 beta=trueAnthropic 专有参数)
let api_format = super::providers::get_claude_api_format(provider);
let target_path = if api_format == "openai_responses" {
"/v1/responses"
} else {
"/v1/chat/completions"
};
let filtered = query.map(|q| {
q.split('&')
.filter(|p| !p.starts_with("beta="))
.collect::<Vec<_>>()
.join("&")
});
match filtered.as_deref() {
Some(q) if !q.is_empty() => format!("{target_path}?{q}"),
_ => target_path.to_string(),
}
} else {
"/v1/chat/completions"
endpoint.to_string()
}
} else {
endpoint
endpoint.to_string()
};
// 使用适配器构建 URL
let url = adapter.build_url(&base_url, effective_endpoint);
// 使用适配器构建 URL
adapter.build_url(&base_url, &effective_endpoint)
};
// 应用模型映射(独立于格式转换)
let (mapped_body, _original_model, _mapped_model) =

View File

@@ -61,12 +61,19 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
pub async fn handle_messages(
State(state): State<ProxyState>,
uri: axum::http::Uri,
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
// 提取完整路径+query如 "/v1/messages?beta=true"),透传客户端 URI
let endpoint = uri
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or(uri.path());
let is_stream = body
.get("stream")
.and_then(|s| s.as_bool())
@@ -77,7 +84,7 @@ pub async fn handle_messages(
let result = match forwarder
.forward_with_retry(
&AppType::Claude,
"/v1/messages",
endpoint,
body.clone(),
headers,
ctx.get_providers(),

View File

@@ -261,6 +261,8 @@ impl ProviderAdapter for ClaudeAdapter {
//
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
//
// ?beta=true 不再由代理注入,而是由客户端自身发送并透传。
let mut base = format!(
"{}/{}",
@@ -273,19 +275,7 @@ impl ProviderAdapter for ClaudeAdapter {
base = base.replace("/v1/v1", "/v1");
}
// 为 Claude 原生 /v1/messages 端点添加 ?beta=true 参数
// 这是某些上游服务(如 DuckCoding验证请求来源的关键参数
// 注意:不要为 OpenAI Chat Completions (/v1/chat/completions) 添加此参数
// 当 apiFormat="openai_chat" 时,请求会转发到 /v1/chat/completions
// 但该端点是 OpenAI 标准,不支持 ?beta=true 参数
if endpoint.contains("/v1/messages")
&& !endpoint.contains("/v1/chat/completions")
&& !endpoint.contains('?')
{
format!("{base}?beta=true")
} else {
base
}
base
}
fn add_auth_headers(&self, request: RequestBuilder, auth: &AuthInfo) -> RequestBuilder {
@@ -522,23 +512,21 @@ mod tests {
#[test]
fn test_build_url_anthropic() {
let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
// ?beta=true 不再由代理注入,而是由客户端自身发送并透传
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages");
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
assert_eq!(url, "https://api.anthropic.com/v1/messages");
}
#[test]
fn test_build_url_openrouter() {
let adapter = ClaudeAdapter::new();
// /v1/messages 端点会自动添加 ?beta=true 参数
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
assert_eq!(url, "https://openrouter.ai/api/v1/messages?beta=true");
assert_eq!(url, "https://openrouter.ai/api/v1/messages");
}
#[test]
fn test_build_url_no_beta_for_other_endpoints() {
let adapter = ClaudeAdapter::new();
// 非 /v1/messages 端点不添加 ?beta=true
let url = adapter.build_url("https://api.anthropic.com", "/v1/complete");
assert_eq!(url, "https://api.anthropic.com/v1/complete");
}
@@ -546,16 +534,14 @@ mod tests {
#[test]
fn test_build_url_preserve_existing_query() {
let adapter = ClaudeAdapter::new();
// 已有查询参数时不重复添加
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?foo=bar");
assert_eq!(url, "https://api.anthropic.com/v1/messages?foo=bar");
// 客户端携带的 query 参数原样透传
let url = adapter.build_url("https://api.anthropic.com", "/v1/messages?beta=true");
assert_eq!(url, "https://api.anthropic.com/v1/messages?beta=true");
}
#[test]
fn test_build_url_no_beta_for_openai_chat_completions() {
let adapter = ClaudeAdapter::new();
// OpenAI Chat Completions 端点不添加 ?beta=true
// 这是 Nvidia 等 apiFormat="openai_chat" 供应商使用的端点
let url = adapter.build_url("https://integrate.api.nvidia.com", "/v1/chat/completions");
assert_eq!(url, "https://integrate.api.nvidia.com/v1/chat/completions");
}

View File

@@ -313,8 +313,19 @@ impl StreamCheckService {
let is_openai_chat = api_format == "openai_chat";
// URL: /v1/chat/completions for openai_chat, /v1/messages?beta=true for anthropic
let url = if is_openai_chat {
let is_full_url = provider
.meta
.as_ref()
.and_then(|m| m.is_full_url)
.unwrap_or(false);
// URL rules:
// - full URL mode: use base_url as-is
// - openai_chat: /v1/chat/completions
// - anthropic: /v1/messages?beta=true
let url = if is_full_url {
base_url.to_string()
} else if is_openai_chat {
if base.ends_with("/v1") {
format!("{base}/chat/completions")
} else {

View File

@@ -661,11 +661,8 @@ fn validate_artifact_size_limit(artifact_name: &str, size: u64) -> Result<(), Ap
let max_mb = MAX_SYNC_ARTIFACT_BYTES / 1024 / 1024;
return Err(localized(
"webdav.sync.artifact_too_large",
format!("artifact {artifact_name} 超过下载上限({} MB", max_mb),
format!(
"Artifact {artifact_name} exceeds download limit ({} MB)",
max_mb
),
format!("artifact {artifact_name} 超过下载上限({max_mb} MB"),
format!("Artifact {artifact_name} exceeds download limit ({max_mb} MB)"),
));
}
Ok(())

View File

@@ -350,8 +350,8 @@ fn copy_entry_with_total_limit<R: Read, W: Write>(
let max_mb = max_total_bytes / 1024 / 1024;
return Err(localized(
"webdav.sync.skills_zip_too_large",
format!("skills.zip 解压后体积超过上限({} MB", max_mb),
format!("skills.zip extracted size exceeds limit ({} MB)", max_mb),
format!("skills.zip 解压后体积超过上限({max_mb} MB"),
format!("skills.zip extracted size exceeds limit ({max_mb} MB)"),
));
}

View File

@@ -255,7 +255,7 @@ function App() {
deleteProvider,
saveUsageScript,
setAsDefaultModel,
} = useProviderActions(activeApp);
} = useProviderActions(activeApp, isProxyRunning);
const disableOmoMutation = useDisableCurrentOmo();
const handleDisableOmo = () => {

View File

@@ -77,10 +77,10 @@ const ToolsPanel: React.FC = () => {
const handleSave = async () => {
try {
const { allow, deny, ...other } = config;
const { profile, allow, deny, ...other } = config;
const newConfig: OpenClawToolsConfig = {
...other,
profile: config.profile,
profile,
allow: allowList.map((item) => item.value).filter((s) => s.trim()),
deny: denyList.map((item) => item.value).filter((s) => s.trim()),
};

View File

@@ -73,9 +73,13 @@ interface ClaudeFormFieldsProps {
apiFormat: ClaudeApiFormat;
onApiFormatChange: (format: ClaudeApiFormat) => void;
// Auth Field (ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY)
// Auth Key Field (ANTHROPIC_AUTH_TOKEN vs ANTHROPIC_API_KEY)
apiKeyField: ClaudeApiKeyField;
onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;
// Full URL mode
isFullUrl: boolean;
onFullUrlChange: (value: boolean) => void;
}
export function ClaudeFormFields({
@@ -112,6 +116,8 @@ export function ClaudeFormFields({
onApiFormatChange,
apiKeyField,
onApiKeyFieldChange,
isFullUrl,
onFullUrlChange,
}: ClaudeFormFieldsProps) {
const { t } = useTranslation();
@@ -181,6 +187,9 @@ export function ClaudeFormFields({
: t("providerForm.apiHint")
}
onManageClick={() => onEndpointModalToggle(true)}
showFullUrlToggle={true}
isFullUrl={isFullUrl}
onFullUrlChange={onFullUrlChange}
/>
)}
@@ -236,17 +245,17 @@ export function ClaudeFormFields({
</div>
)}
{/* 认证字段选择 */}
{/* 认证字段选择(仅非官方供应商显示) */}
{shouldShowModelSelector && (
<div className="space-y-2">
<FormLabel>
<FormLabel htmlFor="apiKeyField">
{t("providerForm.authField", { defaultValue: "认证字段" })}
</FormLabel>
<Select
value={apiKeyField}
onValueChange={(v) => onApiKeyFieldChange(v as ClaudeApiKeyField)}
>
<SelectTrigger>
<SelectTrigger id="apiKeyField" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@@ -17,6 +17,7 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { ApiKeySection } from "./shared";
import { openclawApiProtocols } from "@/config/openclawProviderPresets";
import type { ProviderCategory, OpenClawModel } from "@/types";
@@ -101,6 +102,7 @@ export function OpenClawFormFields({
contextWindow: undefined,
maxTokens: undefined,
cost: undefined,
input: ["text"],
},
]);
};
@@ -339,7 +341,66 @@ export function OpenClawFormFields({
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-3 pt-2">
{/* Context Window, Max Tokens and Reasoning row */}
{/* Reasoning, Input Types row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.reasoning", {
defaultValue: "推理模式",
})}
</label>
<div className="flex items-center h-9 gap-2">
<Switch
checked={model.reasoning ?? false}
onCheckedChange={(checked) =>
handleModelChange(index, "reasoning", checked)
}
/>
<span className="text-xs text-muted-foreground">
{model.reasoning
? t("openclaw.reasoningOn", {
defaultValue: "启用",
})
: t("openclaw.reasoningOff", {
defaultValue: "关闭",
})}
</span>
</div>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.inputTypes", {
defaultValue: "输入类型",
})}
</label>
{/* "text" is checked by default but can be unchecked —
some models genuinely don't support text input, and
OpenClaw works fine with an empty or image-only array. */}
<div className="flex items-center gap-4 h-9">
{(["text", "image"] as const).map((type) => (
<label
key={type}
className="flex items-center gap-1.5 cursor-pointer select-none"
>
<Checkbox
checked={(model.input ?? ["text"]).includes(type)}
onCheckedChange={(checked) => {
const current = model.input ?? ["text"];
const next = checked
? [...new Set([...current, type])]
: current.filter((v) => v !== type);
handleModelChange(index, "input", next);
}}
/>
<span className="text-xs">{type}</span>
</label>
))}
</div>
</div>
<div className="flex-1" />
</div>
{/* Context Window and Max Tokens row */}
<div className="flex items-center gap-2">
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
@@ -383,30 +444,7 @@ export function OpenClawFormFields({
placeholder="32000"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs text-muted-foreground">
{t("openclaw.reasoning", {
defaultValue: "推理模式",
})}
</label>
<div className="flex items-center h-9 gap-2">
<Switch
checked={model.reasoning ?? false}
onCheckedChange={(checked) =>
handleModelChange(index, "reasoning", checked)
}
/>
<span className="text-xs text-muted-foreground">
{model.reasoning
? t("openclaw.reasoningOn", {
defaultValue: "启用",
})
: t("openclaw.reasoningOff", {
defaultValue: "关闭",
})}
</span>
</div>
</div>
<div className="flex-1" />
</div>
{/* Cost row */}

View File

@@ -197,6 +197,9 @@ export function ProviderForm({
setDraftCustomEndpoints([]);
}
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
setLocalIsFullUrl(
appId === "claude" ? (initialData?.meta?.isFullUrl ?? false) : false,
);
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false });
setPricingConfig({
@@ -245,6 +248,20 @@ export function ProviderForm({
[form],
);
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
if (appId !== "claude") return "anthropic";
return initialData?.meta?.apiFormat ?? "anthropic";
});
const [localIsFullUrl, setLocalIsFullUrl] = useState<boolean>(() => {
if (appId !== "claude") return false;
return initialData?.meta?.isFullUrl ?? false;
});
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
setLocalApiFormat(format);
}, []);
const [localApiKeyField, setLocalApiKeyField] = useState<ClaudeApiKeyField>(
() => {
if (appId !== "claude") return "ANTHROPIC_AUTH_TOKEN";
@@ -256,7 +273,6 @@ export function ProviderForm({
return "ANTHROPIC_AUTH_TOKEN";
},
);
const {
apiKey,
handleApiKeyChange,
@@ -291,15 +307,6 @@ export function ProviderForm({
onConfigChange: handleSettingsConfigChange,
});
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
if (appId !== "claude") return "anthropic";
return initialData?.meta?.apiFormat ?? "anthropic";
});
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
setLocalApiFormat(format);
}, []);
const handleApiKeyFieldChange = useCallback(
(field: ClaudeApiKeyField) => {
const prev = localApiKeyField;
@@ -323,7 +330,6 @@ export function ProviderForm({
},
[localApiKeyField, form, handleSettingsConfigChange],
);
const {
codexAuth,
codexConfig,
@@ -888,6 +894,10 @@ export function ProviderForm({
localApiKeyField !== "ANTHROPIC_AUTH_TOKEN"
? localApiKeyField
: undefined,
isFullUrl:
appId === "claude" && category !== "official" && localIsFullUrl
? true
: undefined,
};
onSubmit(payload);
@@ -1335,6 +1345,8 @@ export function ProviderForm({
onApiFormatChange={handleApiFormatChange}
apiKeyField={localApiKeyField}
onApiKeyFieldChange={handleApiKeyFieldChange}
isFullUrl={localIsFullUrl}
onFullUrlChange={setLocalIsFullUrl}
/>
)}

View File

@@ -29,7 +29,7 @@ export function useApiKeyState({
}: UseApiKeyStateProps) {
const [apiKey, setApiKey] = useState(() => {
if (initialConfig) {
return getApiKeyFromConfig(initialConfig, appType);
return getApiKeyFromConfig(initialConfig, appType, apiKeyField);
}
return "";
});
@@ -46,11 +46,11 @@ export function useApiKeyState({
}
// 从配置中提取 API Key如果不存在则返回空字符串
const extracted = getApiKeyFromConfig(initialConfig, appType);
const extracted = getApiKeyFromConfig(initialConfig, appType, apiKeyField);
if (extracted !== apiKey) {
setApiKey(extracted);
}
}, [initialConfig, appType, apiKey]);
}, [initialConfig, appType, apiKeyField, apiKey]);
const handleApiKeyChange = useCallback(
(key: string) => {

View File

@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Zap } from "lucide-react";
import { Zap, Link2 } from "lucide-react";
interface EndpointFieldProps {
id: string;
@@ -13,6 +13,9 @@ interface EndpointFieldProps {
showManageButton?: boolean;
onManageClick?: () => void;
manageButtonLabel?: string;
showFullUrlToggle?: boolean;
isFullUrl?: boolean;
onFullUrlChange?: (value: boolean) => void;
}
export function EndpointField({
@@ -25,6 +28,9 @@ export function EndpointField({
showManageButton = true,
onManageClick,
manageButtonLabel,
showFullUrlToggle = false,
isFullUrl = false,
onFullUrlChange,
}: EndpointFieldProps) {
const { t } = useTranslation();
@@ -55,6 +61,35 @@ export function EndpointField({
placeholder={placeholder}
autoComplete="off"
/>
{showFullUrlToggle && onFullUrlChange && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onFullUrlChange(!isFullUrl)}
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-md border transition-colors ${
isFullUrl
? "border-primary bg-primary/10 text-primary"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/30"
}`}
>
<Link2 className="h-3.5 w-3.5" />
{isFullUrl
? t("providerForm.fullUrlEnabled", {
defaultValue: "完整 URL 模式",
})
: t("providerForm.fullUrlDisabled", {
defaultValue: "标记为完整 URL",
})}
</button>
{isFullUrl && (
<span className="text-xs text-muted-foreground">
{t("providerForm.fullUrlHint", {
defaultValue: "代理将直接使用此 URL不拼接路径",
})}
</span>
)}
</div>
)}
{hint ? (
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg">
<p className="text-xs text-amber-600 dark:text-amber-400">{hint}</p>

View File

@@ -458,6 +458,7 @@ export const openclawProviderPresets: OpenClawProviderPreset[] = [
baseUrl: "https://api.longcat.chat/v1",
apiKey: "",
api: "openai-completions",
authHeader: true,
models: [
{
id: "LongCat-Flash-Chat",

View File

@@ -23,7 +23,7 @@ import { openclawKeys } from "@/hooks/useOpenClaw";
* Hook for managing provider actions (add, update, delete, switch)
* Extracts business logic from App.tsx
*/
export function useProviderActions(activeApp: AppId) {
export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -139,6 +139,23 @@ export function useProviderActions(activeApp: AppId) {
// 切换供应商
const switchProvider = useCallback(
async (provider: Provider) => {
// 阻断逻辑:需要代理的供应商在代理未运行时不允许切换
if (
activeApp === "claude" &&
provider.category !== "official" &&
(provider.meta?.isFullUrl ||
provider.meta?.apiFormat === "openai_chat" ||
provider.meta?.apiFormat === "openai_responses") &&
!isProxyRunning
) {
toast.warning(
t("notifications.proxyRequiredForSwitch", {
defaultValue: "此供应商需要代理服务,请先启动代理",
}),
);
return;
}
try {
const result = await switchProviderMutation.mutateAsync(provider.id);
await syncClaudePlugin(provider);
@@ -192,7 +209,7 @@ export function useProviderActions(activeApp: AppId) {
// 错误提示由 mutation 处理
}
},
[switchProviderMutation, syncClaudePlugin, activeApp, t],
[switchProviderMutation, syncClaudePlugin, activeApp, isProxyRunning, t],
);
// 删除供应商

View File

@@ -43,6 +43,8 @@ export function useProxyStatus() {
onSuccess: (info) => {
toast.success(
t("proxy.server.started", {
address: info.address,
port: info.port,
defaultValue: `代理服务已启动 - ${info.address}:${info.port}`,
}),
{ closeButton: true },

View File

@@ -173,6 +173,7 @@
"settingsSaveFailed": "Failed to save settings: {{error}}",
"openAIChatFormatHint": "This provider uses OpenAI Chat format and requires the proxy service to be enabled",
"openAIFormatHint": "This provider uses OpenAI-compatible format and requires the proxy service to be enabled",
"proxyRequiredForSwitch": "This provider requires the proxy service. Please start the proxy first",
"openLinkFailed": "Failed to open link",
"openclawModelsRegistered": "Models have been registered to /model list",
"openclawDefaultModelSet": "Set as default model",
@@ -736,6 +737,9 @@
"anthropicReasoningModel": "Reasoning Model (Thinking)",
"apiFormat": "API Format",
"apiFormatHint": "Select the input format for the provider's API",
"fullUrlEnabled": "Full URL Mode",
"fullUrlDisabled": "Mark as Full URL",
"fullUrlHint": "Proxy will use this URL as-is",
"apiFormatAnthropic": "Anthropic Messages (Native)",
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)",
"apiFormatOpenAIResponses": "OpenAI Responses API (Requires proxy)",
@@ -1336,6 +1340,7 @@
"reasoning": "Reasoning Mode",
"reasoningOn": "Enabled",
"reasoningOff": "Disabled",
"inputTypes": "Input Types",
"inputCost": "Input Cost ($/M tokens)",
"outputCost": "Output Cost ($/M tokens)",
"advancedOptions": "Advanced Options",
@@ -1372,12 +1377,6 @@
"unsupportedProfileTitle": "Unsupported tools profile detected",
"unsupportedProfileDescription": "The current tools.profile value '{{value}}' is not in the supported OpenClaw list. It will be preserved until you choose a new value.",
"unsupportedProfileLabel": "unsupported",
"profiles": {
"default": "Default",
"strict": "Strict",
"permissive": "Permissive",
"custom": "Custom"
},
"allowList": "Allow List",
"denyList": "Deny List",
"patternPlaceholder": "Tool name or pattern",

View File

@@ -173,6 +173,7 @@
"settingsSaveFailed": "設定の保存に失敗しました: {{error}}",
"openAIChatFormatHint": "このプロバイダーは OpenAI Chat フォーマットを使用しており、プロキシサービスの有効化が必要です",
"openAIFormatHint": "このプロバイダーは OpenAI 互換フォーマットを使用しており、プロキシサービスの有効化が必要です",
"proxyRequiredForSwitch": "このプロバイダーにはプロキシが必要です。先にプロキシを起動してください",
"openLinkFailed": "リンクを開けませんでした",
"openclawModelsRegistered": "モデルが /model リストに登録されました",
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
@@ -736,6 +737,9 @@
"anthropicReasoningModel": "推論モデルThinking",
"apiFormat": "API フォーマット",
"apiFormatHint": "プロバイダー API の入力フォーマットを選択",
"fullUrlEnabled": "フル URL モード",
"fullUrlDisabled": "フル URL として設定",
"fullUrlHint": "プロキシはこの URL をそのまま使用",
"apiFormatAnthropic": "Anthropic Messagesネイティブ",
"apiFormatOpenAIChat": "OpenAI Chat Completionsプロキシが必要",
"apiFormatOpenAIResponses": "OpenAI Responses APIプロキシが必要",
@@ -1336,6 +1340,7 @@
"reasoning": "推論モード",
"reasoningOn": "有効",
"reasoningOff": "無効",
"inputTypes": "入力タイプ",
"inputCost": "入力コスト ($/M トークン)",
"outputCost": "出力コスト ($/M トークン)",
"advancedOptions": "詳細オプション",
@@ -1372,12 +1377,6 @@
"unsupportedProfileTitle": "未対応のツールプロファイルを検出しました",
"unsupportedProfileDescription": "現在の tools.profile の値 '{{value}}' は OpenClaw の対応リストにありません。新しい値を選択するまでこの値を保持します。",
"unsupportedProfileLabel": "未対応",
"profiles": {
"default": "デフォルト",
"strict": "厳格",
"permissive": "寛容",
"custom": "カスタム"
},
"allowList": "許可リスト",
"denyList": "拒否リスト",
"patternPlaceholder": "ツール名またはパターン",

View File

@@ -173,6 +173,7 @@
"settingsSaveFailed": "保存设置失败:{{error}}",
"openAIChatFormatHint": "此供应商使用 OpenAI Chat 格式,需要开启代理服务才能正常使用",
"openAIFormatHint": "此供应商使用 OpenAI 兼容格式,需要开启代理服务才能正常使用",
"proxyRequiredForSwitch": "此供应商需要代理服务,请先启动代理",
"openLinkFailed": "链接打开失败",
"openclawModelsRegistered": "模型已注册到 /model 列表",
"openclawDefaultModelSet": "已设为默认模型",
@@ -736,6 +737,9 @@
"anthropicReasoningModel": "推理模型 (Thinking)",
"apiFormat": "API 格式",
"apiFormatHint": "选择供应商 API 的输入格式",
"fullUrlEnabled": "完整 URL 模式",
"fullUrlDisabled": "标记为完整 URL",
"fullUrlHint": "代理将直接使用此 URL不拼接路径",
"apiFormatAnthropic": "Anthropic Messages (原生)",
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)",
"apiFormatOpenAIResponses": "OpenAI Responses API (需开启代理)",
@@ -1336,6 +1340,7 @@
"reasoning": "推理模式",
"reasoningOn": "启用",
"reasoningOff": "关闭",
"inputTypes": "输入类型",
"inputCost": "输入价格 ($/M tokens)",
"outputCost": "输出价格 ($/M tokens)",
"advancedOptions": "高级选项",
@@ -1372,12 +1377,6 @@
"unsupportedProfileTitle": "检测到不受支持的工具配置",
"unsupportedProfileDescription": "当前 tools.profile 的值“{{value}}”不在 OpenClaw 支持列表内。在你手动选择新值之前,它会被保留。",
"unsupportedProfileLabel": "不受支持",
"profiles": {
"default": "默认",
"strict": "严格",
"permissive": "宽松",
"custom": "自定义"
},
"allowList": "允许列表",
"denyList": "拒绝列表",
"patternPlaceholder": "工具名称或模式",

View File

@@ -151,6 +151,8 @@ export interface ProviderMeta {
apiFormat?: "anthropic" | "openai_chat" | "openai_responses";
// Claude 认证字段名
apiKeyField?: ClaudeApiKeyField;
// 是否将 base_url 视为完整 API 端点(代理直接使用此 URL不拼接路径
isFullUrl?: boolean;
// Prompt cache key for OpenAI-compatible endpoints (improves cache hit rate)
promptCacheKey?: string;
}

View File

@@ -170,6 +170,7 @@ export const hasCommonConfigSnippet = (
export const getApiKeyFromConfig = (
jsonString: string,
appType?: string,
apiKeyField?: string,
): string => {
try {
const config = JSON.parse(jsonString);
@@ -203,11 +204,17 @@ export const getApiKeyFromConfig = (
const token = env.ANTHROPIC_AUTH_TOKEN;
const apiKey = env.ANTHROPIC_API_KEY;
const value =
typeof token === "string"
? token
: typeof apiKey === "string"
apiKeyField === "ANTHROPIC_API_KEY"
? typeof apiKey === "string"
? apiKey
: "";
: typeof token === "string"
? token
: ""
: typeof token === "string"
? token
: typeof apiKey === "string"
? apiKey
: "";
return value;
} catch (err) {
return "";
@@ -341,13 +348,20 @@ export const setApiKeyInConfig = (
return JSON.stringify(config, null, 2);
}
// Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则使用 apiKeyField 或默认 AUTH_TOKEN 字段)
if ("ANTHROPIC_AUTH_TOKEN" in env) {
env.ANTHROPIC_AUTH_TOKEN = apiKey;
} else if ("ANTHROPIC_API_KEY" in env) {
env.ANTHROPIC_API_KEY = apiKey;
// Claude API Key: follow the selected field when provided.
const preferredClaudeField = apiKeyField ?? "ANTHROPIC_AUTH_TOKEN";
const alternateClaudeField =
preferredClaudeField === "ANTHROPIC_API_KEY"
? "ANTHROPIC_AUTH_TOKEN"
: "ANTHROPIC_API_KEY";
if (preferredClaudeField in env) {
env[preferredClaudeField] = apiKey;
} else if (alternateClaudeField in env) {
env[preferredClaudeField] = apiKey;
delete env[alternateClaudeField];
} else if (createIfMissing) {
env[apiKeyField ?? "ANTHROPIC_AUTH_TOKEN"] = apiKey;
env[preferredClaudeField] = apiKey;
} else {
return jsonString;
}

View File

@@ -0,0 +1,118 @@
import type { ReactNode } from "react";
import { renderHook, act, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useProxyStatus } from "@/hooks/useProxyStatus";
import { createTestQueryClient } from "../utils/testQueryClient";
const toastSuccessMock = vi.fn();
const toastErrorMock = vi.fn();
const invokeMock = vi.fn();
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => toastSuccessMock(...args),
error: (...args: unknown[]) => toastErrorMock(...args),
},
}));
vi.mock("@tauri-apps/api/core", () => ({
invoke: (...args: unknown[]) => invokeMock(...args),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === "proxy.server.started") {
return `代理服务已启动 - ${options?.address}:${options?.port}`;
}
if (typeof options?.defaultValue === "string") {
return options.defaultValue;
}
return key;
},
}),
}));
interface WrapperProps {
children: ReactNode;
}
function createWrapper() {
const queryClient = createTestQueryClient();
const wrapper = ({ children }: WrapperProps) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return { wrapper, queryClient };
}
describe("useProxyStatus", () => {
beforeEach(() => {
invokeMock.mockReset();
toastSuccessMock.mockReset();
toastErrorMock.mockReset();
invokeMock.mockImplementation((command: string) => {
if (command === "get_proxy_status") {
return Promise.resolve({
running: false,
address: "127.0.0.1",
port: 15721,
active_connections: 0,
total_requests: 0,
success_requests: 0,
failed_requests: 0,
success_rate: 0,
uptime_seconds: 0,
current_provider: null,
current_provider_id: null,
last_request_at: null,
last_error: null,
failover_count: 0,
});
}
if (command === "get_proxy_takeover_status") {
return Promise.resolve({
claude: false,
codex: false,
gemini: false,
opencode: false,
openclaw: false,
});
}
if (command === "start_proxy_server") {
return Promise.resolve({
address: "127.0.0.1",
port: 15721,
started_at: "2026-03-10T00:00:00Z",
});
}
return Promise.resolve(null);
});
});
it("shows interpolated address and port after proxy server starts", async () => {
const { wrapper } = createWrapper();
const { result } = renderHook(() => useProxyStatus(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.startProxyServer();
});
expect(toastSuccessMock).toHaveBeenCalledWith(
"代理服务已启动 - 127.0.0.1:15721",
{ closeButton: true },
);
});
});

View File

@@ -52,4 +52,3 @@ describe("Codex TOML utils", () => {
expect(extractCodexModelName(output2)).toBe("new-model");
});
});