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
26 changes: 11 additions & 15 deletions apps/server/src/anthropicAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,7 @@ export function anthropicToOpenAIChat(payload: Record<string, unknown>): Record<
function: {
name: block.name,
arguments:
typeof block.input === "string"
? block.input
: JSON.stringify(block.input ?? {}),
typeof block.input === "string" ? block.input : JSON.stringify(block.input ?? {}),
},
});
} else if (block.type === "text" && typeof block.text === "string") {
Expand Down Expand Up @@ -187,7 +185,10 @@ export function anthropicToOpenAIChat(payload: Record<string, unknown>): Record<
// are lowercased because some OpenAI-compatible upstreams reject uppercase.
if (Array.isArray(payload.tools)) {
const SERVER_TOOL_PREFIXES = [
"web_search", "computer", "text_editor", "bash_",
"web_search",
"computer",
"text_editor",
"bash_",
"code_execution",
];
const tools = payload.tools
Expand Down Expand Up @@ -267,7 +268,8 @@ export async function openAIChatToAnthropicMessages(
): Promise<Response> {
const raw = (await openaiResponse.json()) as OpenAIChatResponse | Record<string, unknown>;
if (!openaiResponse.ok) {
const upstreamError = isRecord(raw.error) ? raw.error : {};
const rawRecord = raw as Record<string, unknown>;
const upstreamError = isRecord(rawRecord.error) ? rawRecord.error : {};
const message =
typeof upstreamError.message === "string"
? upstreamError.message
Expand Down Expand Up @@ -384,10 +386,7 @@ export function openAIChatStreamToAnthropicStream(
let textBlockOpen = false;
let textBlockStarted = false;
let finalStopReason = "end_turn";
const toolCallsByIndex = new Map<
number,
{ id: string; name: string; arguments: string }
>();
const toolCallsByIndex = new Map<number, { id: string; name: string; arguments: string }>();
// Track which tool indexes we've already emitted a content_block_start for
// so we can append them after the text block closes.
const emittedToolStarts = new Set<number>();
Expand Down Expand Up @@ -460,18 +459,15 @@ export function openAIChatStreamToAnthropicStream(
// we saw in this stream so arguments accumulate together.
const fallbackIndex =
toolCallsByIndex.size > 0 ? Math.max(...toolCallsByIndex.keys()) : 0;
const idx =
typeof rawTc.index === "number" ? rawTc.index : fallbackIndex;
const idx = typeof rawTc.index === "number" ? rawTc.index : fallbackIndex;
const fn = isRecord(rawTc.function) ? rawTc.function : {};
const existing = toolCallsByIndex.get(idx);
const id =
typeof rawTc.id === "string" && rawTc.id
? rawTc.id
: existing?.id ?? `toolu_${randomUUID().replace(/-/gu, "").slice(0, 24)}`;
: (existing?.id ?? `toolu_${randomUUID().replace(/-/gu, "").slice(0, 24)}`);
const name =
typeof fn.name === "string" && fn.name
? fn.name
: existing?.name ?? "";
typeof fn.name === "string" && fn.name ? fn.name : (existing?.name ?? "");
const args =
(existing?.arguments ?? "") +
(typeof fn.arguments === "string" ? fn.arguments : "");
Expand Down
42 changes: 24 additions & 18 deletions apps/server/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,21 +111,26 @@ function responsesContentToChatContent(content: unknown): unknown {
if (!Array.isArray(content)) return content ?? "";

const parts: Array<
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } }
> = content.flatMap((part) => {
if (!isRecord(part)) return [];
if (
(part.type === "input_text" || part.type === "output_text" || part.type === "text") &&
typeof part.text === "string"
) {
return [{ type: "text", text: part.text }];
}
if (part.type === "input_image" && typeof part.image_url === "string") {
return [{ type: "image_url", image_url: { url: part.image_url } }];
}
return [];
});
{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }
> = content.flatMap(
(
part,
): Array<
{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }
> => {
if (!isRecord(part)) return [];
if (
(part.type === "input_text" || part.type === "output_text" || part.type === "text") &&
typeof part.text === "string"
) {
return [{ type: "text", text: part.text }];
}
if (part.type === "input_image" && typeof part.image_url === "string") {
return [{ type: "image_url", image_url: { url: part.image_url } }];
}
return [];
},
);

if (parts.length === 0) return "";
if (parts.every((part) => part.type === "text")) {
Expand Down Expand Up @@ -254,9 +259,10 @@ export function responsesPayloadToChatPayload(
return responsesPayloadToChatPayloadWithContext(payload).chatPayload;
}

export function responsesPayloadToChatPayloadWithContext(
payload: Record<string, unknown>,
): { chatPayload: Record<string, unknown>; contextInputItems: unknown[] } {
export function responsesPayloadToChatPayloadWithContext(payload: Record<string, unknown>): {
chatPayload: Record<string, unknown>;
contextInputItems: unknown[];
} {
const contextInputItems = responseContextInputItems(payload);
const result: Record<string, unknown> = {
model: payload.model,
Expand Down
43 changes: 25 additions & 18 deletions apps/server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ function headerString(
const lowerName = name.toLowerCase();
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === lowerName)?.[1];
if (Array.isArray(entry)) return entry[0] ?? "";
return entry ?? "";
return (entry as string | undefined) ?? "";
}

function isLoopbackAddress(address: string | null | undefined): boolean {
Expand Down Expand Up @@ -231,12 +231,14 @@ const gatewayEffectRouteLayer = HttpRouter.add(
// cookies) happens inside the adapter.
const secretEntries = yield* Effect.all(
upstream.secrets.map((def) =>
secretStore.get(gatewaySecretName(upstream.id, def.id)).pipe(
Effect.map((bytes): [string, string] => [
def.id,
bytes ? new TextDecoder().decode(bytes).trim() : "",
]),
),
secretStore
.get(gatewaySecretName(upstream.id, def.id))
.pipe(
Effect.map((bytes): [string, string] => [
def.id,
bytes ? new TextDecoder().decode(bytes).trim() : "",
]),
),
),
);
const secrets: Record<string, string> = {};
Expand All @@ -255,7 +257,8 @@ const gatewayEffectRouteLayer = HttpRouter.add(

try {
if (isResponses) {
const { chatPayload, contextInputItems } = responsesPayloadToChatPayloadWithContext(payload);
const { chatPayload, contextInputItems } =
responsesPayloadToChatPayloadWithContext(payload);
chatPayload.model = model;
const upstreamResponse = yield* Effect.tryPromise({
try: () =>
Expand All @@ -273,7 +276,11 @@ const gatewayEffectRouteLayer = HttpRouter.add(
? chatStreamToResponsesStream(upstreamResponse, requestedModel, contextInputItems)
: yield* Effect.tryPromise({
try: () =>
chatResponseToResponsesResponse(upstreamResponse, requestedModel, contextInputItems),
chatResponseToResponsesResponse(
upstreamResponse,
requestedModel,
contextInputItems,
),
catch: () => ({
message: "Failed to convert gateway response.",
status: 502 as const,
Expand Down Expand Up @@ -364,9 +371,7 @@ const anthropicGatewayEffectRouteLayer = HttpRouter.add(
);
const messages = isRecord(body) && Array.isArray(body.messages) ? body.messages : [];
const system = isRecord(body) && typeof body.system === "string" ? body.system : "";
const inputTokens = Math.ceil(
(system.length + JSON.stringify(messages).length) / 4,
);
const inputTokens = Math.ceil((system.length + JSON.stringify(messages).length) / 4);
return HttpServerResponse.jsonUnsafe({ input_tokens: Math.max(inputTokens, 1) });
}

Expand Down Expand Up @@ -401,12 +406,14 @@ const anthropicGatewayEffectRouteLayer = HttpRouter.add(

const secretEntries = yield* Effect.all(
upstream.secrets.map((def) =>
secretStore.get(gatewaySecretName(upstream.id, def.id)).pipe(
Effect.map((bytes): [string, string] => [
def.id,
bytes ? new TextDecoder().decode(bytes).trim() : "",
]),
),
secretStore
.get(gatewaySecretName(upstream.id, def.id))
.pipe(
Effect.map((bytes): [string, string] => [
def.id,
bytes ? new TextDecoder().decode(bytes).trim() : "",
]),
),
),
);
const secrets: Record<string, string> = {};
Expand Down
79 changes: 53 additions & 26 deletions apps/web/src/routes/_chat.settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,9 @@ function channelIsComplete(
): boolean {
return Boolean(
channel &&
channel.baseUrl.trim().length > 0 &&
channelHasModel(channel) &&
channelHasRequiredSecrets(channel, statuses),
channel.baseUrl.trim().length > 0 &&
channelHasModel(channel) &&
channelHasRequiredSecrets(channel, statuses),
);
}

Expand Down Expand Up @@ -739,9 +739,10 @@ function SettingsRouteView() {
});
}, [agentConfigStatusQuery.data?.agents, agentConfigStatusQuery.isError]);
const enabledChannelCount =
gatewayConfigQuery.data?.channels.filter((channel) =>
channel.enabled &&
channelIsComplete(channel, channelSecretStatuses(channel.id, gatewaySecretStatuses)),
gatewayConfigQuery.data?.channels.filter(
(channel) =>
channel.enabled &&
channelIsComplete(channel, channelSecretStatuses(channel.id, gatewaySecretStatuses)),
).length ?? 0;
const updateGatewayConfigMutation = useMutation({
mutationFn: async (patch: GatewayConfigPatch) => {
Expand Down Expand Up @@ -2626,11 +2627,32 @@ function SettingsRouteView() {
</p>
<div className="space-y-1">
{[
{ label: "Root", url: resolveWsHttpUrl("/gateway/openai/v1").replace(/\?token=.*/u, "") },
{ label: "Chat", url: resolveWsHttpUrl("/gateway/openai/v1/chat/completions").replace(/\?token=.*/u, "") },
{ label: "Models", url: resolveWsHttpUrl("/gateway/openai/v1/models").replace(/\?token=.*/u, "") },
{ label: "Responses", url: resolveWsHttpUrl("/gateway/openai/v1/responses").replace(/\?token=.*/u, "") },
{ label: "Anthropic", url: resolveWsHttpUrl("/gateway/anthropic").replace(/\?token=.*/u, "") },
{
label: "Root",
url: resolveWsHttpUrl("/gateway/openai/v1").replace(/\?token=.*/u, ""),
},
{
label: "Chat",
url: resolveWsHttpUrl("/gateway/openai/v1/chat/completions").replace(
/\?token=.*/u,
"",
),
},
{
label: "Models",
url: resolveWsHttpUrl("/gateway/openai/v1/models").replace(/\?token=.*/u, ""),
},
{
label: "Responses",
url: resolveWsHttpUrl("/gateway/openai/v1/responses").replace(
/\?token=.*/u,
"",
),
},
{
label: "Anthropic",
url: resolveWsHttpUrl("/gateway/anthropic").replace(/\?token=.*/u, ""),
},
].map((ep) => (
<div
key={ep.label}
Expand Down Expand Up @@ -2672,8 +2694,9 @@ function SettingsRouteView() {
<div>
<h4 className="mb-1 text-sm font-semibold text-foreground">Agent Setup</h4>
<p className="mb-3 text-xs text-muted-foreground">
将网关配置写入各 Agent 的本地配置文件。Codex / Claude Code 经网关协议转换;OpenCode /
Kilo / Cursor / pi / Cline 经 OpenAI 标准协议转发。点击写入后,手动启动对应 Agent 即可用网关。
将网关配置写入各 Agent 的本地配置文件。Codex / Claude Code
经网关协议转换;OpenCode / Kilo / Cursor / pi / Cline 经 OpenAI
标准协议转发。点击写入后,手动启动对应 Agent 即可用网关。
</p>
{agentConfigStatusQuery.isError ? (
<p className="mb-3 rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-600 dark:text-amber-300">
Expand Down Expand Up @@ -2795,7 +2818,8 @@ function SettingsRouteView() {
if (checked && !channelIsComplete(serverChannel, channelSecrets)) {
toastManager.add({
title: "渠道配置未完成",
description: "请先配置 Base URL、至少一个模型,以及该渠道要求的全部密钥。",
description:
"请先配置 Base URL、至少一个模型,以及该渠道要求的全部密钥。",
type: "info",
});
return;
Expand Down Expand Up @@ -3686,7 +3710,8 @@ function GatewayChannelCard(props: {
const totalSecrets = props.serverChannel?.secrets.length ?? 0;
const configuredSecrets = props.secretsStatus.filter((s) => s.hasApiKey).length;
const hasAllSecrets =
Boolean(props.serverChannel) && channelHasRequiredSecrets(props.serverChannel, props.secretsStatus);
Boolean(props.serverChannel) &&
channelHasRequiredSecrets(props.serverChannel, props.secretsStatus);
const hasModel = channelHasModel(props.serverChannel);
const hasBaseUrl = Boolean(props.serverChannel?.baseUrl.trim());
const canEnable = Boolean(props.serverChannel) && hasAllSecrets && hasModel && hasBaseUrl;
Expand Down Expand Up @@ -3871,9 +3896,7 @@ function ChannelModelsEditor(props: {
</span>
</div>
{props.models.length === 0 ? (
<p className="text-[11px] text-muted-foreground/60">
未配置模型,将使用渠道默认模型字段。
</p>
<p className="text-[11px] text-muted-foreground/60">未配置模型,将使用渠道默认模型字段。</p>
) : (
<div className="space-y-1">
{props.models.map((model, index) => (
Expand Down Expand Up @@ -3920,7 +3943,12 @@ function ChannelModelsEditor(props: {
placeholder="模型 id,如 deepseek-reasoner"
className="h-7 flex-1 text-xs"
/>
<Button size="xs" variant="outline" onClick={addModel} disabled={props.disabled || !newId.trim()}>
<Button
size="xs"
variant="outline"
onClick={addModel}
disabled={props.disabled || !newId.trim()}
>
添加
</Button>
</div>
Expand All @@ -3938,7 +3966,10 @@ function ChannelAgentMappingsEditor(props: {
const options = props.models;
// Builds a mappings object that omits empty values, so we never assign
// `undefined` to an optional field (forbidden by exactOptionalPropertyTypes).
const buildMappings = (agent: "codex" | "claude", value: string): {
const buildMappings = (
agent: "codex" | "claude",
value: string,
): {
codex?: string;
claude?: string;
} => {
Expand All @@ -3949,11 +3980,7 @@ function ChannelAgentMappingsEditor(props: {
if (trimmed) next[agent] = trimmed;
return next;
};
const renderSelect = (
agent: "codex" | "claude",
label: string,
description: string,
) => {
const renderSelect = (agent: "codex" | "claude", label: string, description: string) => {
const current = props.mappings[agent];
return (
<div className="space-y-1">
Expand Down Expand Up @@ -4027,7 +4054,7 @@ function ChannelSecretsRow(props: {
label={def.label}
sensitive={def.sensitive}
hasApiKey={hasApiKey}
disabled={props.disabled}
disabled={props.disabled ?? false}
onSet={(value) => props.onSetSecret(def.id, value)}
onRemove={() => props.onRemoveSecret(def.id)}
/>
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import type { NativeApi, DesktopBridge } from "@peakcode/contracts";

interface ImportMetaEnv {
readonly APP_VERSION: string;
/** WebSocket server URL (set when server binds to a specific host). */
readonly VITE_WS_URL: string;
/** WebSocket server port (set when server binds to a wildcard address like 0.0.0.0). */
readonly VITE_WS_PORT: string;
}

interface ImportMeta {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/wsTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,15 @@ function makeSocketUrl(explicitUrl: string | null): string {
if (explicitUrl) return resolveRpcUrl(explicitUrl);
const bridgeUrl = window.desktopBridge?.getWsUrl();
const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
// VITE_WS_PORT is set when the server binds to a wildcard address (0.0.0.0 / ::),
// so the browser constructs the WS URL from its own hostname + the correct server port.
const envWsPort = import.meta.env.VITE_WS_PORT as string | undefined;
const rawUrl =
bridgeUrl && bridgeUrl.length > 0
? bridgeUrl
: envUrl && envUrl.length > 0
? envUrl
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`;
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${envWsPort && envWsPort.length > 0 ? envWsPort : window.location.port}`;
return resolveRpcUrl(rawUrl);
}

Expand Down
Loading