refactor(app): RISKY pass R1-R3 — config-driven modal, cursor frame dedup, chunk helper

R1: merge AddOpenAICompatibleModal + AddAnthropicCompatibleModal → AddCompatibleModal (variant config-driven, ~180 dup removed, preserves per-variant useEffect behavior)
R3: extract readCursorFrame() helper — dedup protobuf frame header/decompress loop (JSON+SSE transforms, byte-identical)
R2: add chatChunkSse() helper, wire 7 cursor SSE scaffolds (byte-identical, cursor golden pass)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
decolua
2026-06-14 21:54:35 +07:00
co-authored by Cursor
parent fbf973f2e7
commit 24a2d19bd7
9 changed files with 344 additions and 585 deletions
+92 -196
View File
@@ -9,6 +9,7 @@ import {
import { buildCursorHeaders } from "../utils/cursorChecksum.js";
import { estimateUsage } from "../utils/usageTracking.js";
import { SSE_DONE, SSE_HEADERS } from "../utils/sseConstants.js";
import { chatChunkSse } from "../utils/sse.js";
import { FORMATS } from "../translator/formats.js";
import { proxyAwareFetch } from "../utils/proxyFetch.js";
import zlib from "zlib";
@@ -99,6 +100,32 @@ function decompressPayload(payload, flags) {
return payload;
}
// Read one cursor protobuf frame: header + bounds + decompress. Returns status + payload + new offset.
function readCursorFrame(buffer, offset, frameNum, tag) {
if (offset + 5 > buffer.length) {
debugLog(`[CURSOR BUFFER${tag}] Reached end, offset=${offset}, remaining=${buffer.length - offset}`);
return { status: "done" };
}
const flags = buffer[offset];
const length = buffer.readUInt32BE(offset + 1);
debugLog(`[CURSOR BUFFER${tag}] Frame ${frameNum + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}`);
if (offset + 5 + length > buffer.length) {
debugLog(`[CURSOR BUFFER${tag}] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}`);
return { status: "done" };
}
let payload = buffer.slice(offset + 5, offset + 5 + length);
const newOffset = offset + 5 + length;
payload = decompressPayload(payload, flags);
if (!payload) {
debugLog(`[CURSOR BUFFER${tag}] Frame ${frameNum + 1}: decompression failed, skipping`);
return { status: "skip", offset: newOffset };
}
return { status: "ok", payload, offset: newOffset };
}
function createErrorResponse(jsonError) {
const errorMsg = jsonError?.error?.details?.[0]?.debug?.details?.title
|| jsonError?.error?.details?.[0]?.debug?.details?.detail
@@ -287,36 +314,12 @@ export class CursorExecutor extends BaseExecutor {
debugLog(`[CURSOR BUFFER] Total length: ${buffer.length} bytes`);
while (offset < buffer.length) {
if (offset + 5 > buffer.length) {
debugLog(
`[CURSOR BUFFER] Reached end, offset=${offset}, remaining=${buffer.length - offset}`
);
break;
}
const flags = buffer[offset];
const length = buffer.readUInt32BE(offset + 1);
debugLog(
`[CURSOR BUFFER] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}`
);
if (offset + 5 + length > buffer.length) {
debugLog(
`[CURSOR BUFFER] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}`
);
break;
}
let payload = buffer.slice(offset + 5, offset + 5 + length);
offset += 5 + length;
const frame = readCursorFrame(buffer, offset, frameCount, "");
if (frame.status === "done") break;
offset = frame.offset;
frameCount++;
payload = decompressPayload(payload, flags);
if (!payload) {
debugLog(`[CURSOR BUFFER] Frame ${frameCount}: decompression failed, skipping`);
continue;
}
if (frame.status === "skip") continue;
const payload = frame.payload;
// Check for JSON error frames (byte guard: skip toString on non-JSON frames)
if (payload.length > 0 && payload[0] === 0x7b) {
@@ -467,36 +470,12 @@ export class CursorExecutor extends BaseExecutor {
debugLog(`[CURSOR BUFFER SSE] Total length: ${buffer.length} bytes`);
while (offset < buffer.length) {
if (offset + 5 > buffer.length) {
debugLog(
`[CURSOR BUFFER SSE] Reached end, offset=${offset}, remaining=${buffer.length - offset}`
);
break;
}
const flags = buffer[offset];
const length = buffer.readUInt32BE(offset + 1);
debugLog(
`[CURSOR BUFFER SSE] Frame ${frameCount + 1}: flags=0x${flags.toString(16).padStart(2, "0")}, length=${length}`
);
if (offset + 5 + length > buffer.length) {
debugLog(
`[CURSOR BUFFER SSE] Incomplete frame, offset=${offset}, length=${length}, buffer.length=${buffer.length}`
);
break;
}
let payload = buffer.slice(offset + 5, offset + 5 + length);
offset += 5 + length;
const frame = readCursorFrame(buffer, offset, frameCount, " SSE");
if (frame.status === "done") break;
offset = frame.offset;
frameCount++;
payload = decompressPayload(payload, flags);
if (!payload) {
debugLog(`[CURSOR BUFFER SSE] Frame ${frameCount}: decompression failed, skipping`);
continue;
}
if (frame.status === "skip") continue;
const payload = frame.payload;
// Check for JSON error frames (byte-guard: only decode if starts with '{')
if (payload[0] === 0x7b) {
@@ -543,21 +522,7 @@ export class CursorExecutor extends BaseExecutor {
const tc = result.toolCall;
if (chunks.length === 0) {
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: { role: "assistant", content: "" },
finish_reason: null
}
]
})}\n\n`
);
chunks.push(chatChunkSse({ id: responseId, created, model, delta: { role: "assistant", content: "" } }));
}
if (toolCallsMap.has(tc.id)) {
@@ -570,33 +535,22 @@ export class CursorExecutor extends BaseExecutor {
// Stream the delta arguments
if (tc.function.arguments) {
emittedToolCallIds.add(tc.id);
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
chunks.push(chatChunkSse({
id: responseId, created, model,
delta: {
tool_calls: [
{
index: 0,
delta: {
tool_calls: [
{
index: existing.index,
id: tc.id,
type: "function",
function: {
name: tc.function.name,
arguments: tc.function.arguments
}
}
]
},
finish_reason: null
index: existing.index,
id: tc.id,
type: "function",
function: {
name: tc.function.name,
arguments: tc.function.arguments
}
}
]
})}\n\n`
);
}
}));
}
} else {
// New tool call - assign index and add to map
@@ -607,56 +561,34 @@ export class CursorExecutor extends BaseExecutor {
// Stream initial tool call with name
emittedToolCallIds.add(tc.id);
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
chunks.push(chatChunkSse({
id: responseId, created, model,
delta: {
tool_calls: [
{
index: 0,
delta: {
tool_calls: [
{
index: toolCallIndex,
id: tc.id,
type: "function",
function: {
name: tc.function.name,
arguments: tc.function.arguments
}
}
]
},
finish_reason: null
index: toolCallIndex,
id: tc.id,
type: "function",
function: {
name: tc.function.name,
arguments: tc.function.arguments
}
}
]
})}\n\n`
);
}
}));
}
}
if (result.text) {
totalContent += result.text;
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta:
chunks.length === 0 && toolCalls.length === 0
? { role: "assistant", content: result.text }
: { content: result.text },
finish_reason: null
}
]
})}\n\n`
);
chunks.push(chatChunkSse({
id: responseId, created, model,
delta:
chunks.length === 0 && toolCalls.length === 0
? { role: "assistant", content: result.text }
: { content: result.text }
}));
}
if (isComposerModel(model) && result.thinking) {
@@ -666,24 +598,13 @@ export class CursorExecutor extends BaseExecutor {
const deltaContent = visibleContent.slice(emittedComposerThinkingContentLength);
emittedComposerThinkingContentLength = visibleContent.length;
totalContent += deltaContent;
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta:
chunks.length === 0 && toolCalls.length === 0
? { role: "assistant", content: deltaContent }
: { content: deltaContent },
finish_reason: null
}
]
})}\n\n`
);
chunks.push(chatChunkSse({
id: responseId, created, model,
delta:
chunks.length === 0 && toolCalls.length === 0
? { role: "assistant", content: deltaContent }
: { content: deltaContent }
}));
}
}
}
@@ -709,53 +630,28 @@ export class CursorExecutor extends BaseExecutor {
// Emit SSE chunk for the finalized tool call if not already emitted
if (!emittedToolCallIds.has(tc.id)) {
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
chunks.push(chatChunkSse({
id: responseId, created, model,
delta: {
tool_calls: [
{
index: 0,
delta: {
tool_calls: [
{
index: toolCallIndex,
id: tc.id,
type: "function",
function: {
name: tc.function.name,
arguments: tc.function.arguments
}
}
]
},
finish_reason: null
index: toolCallIndex,
id: tc.id,
type: "function",
function: {
name: tc.function.name,
arguments: tc.function.arguments
}
}
]
})}\n\n`
);
}
}));
}
}
}
if (chunks.length === 0 && toolCalls.length === 0) {
chunks.push(
`data: ${JSON.stringify({
id: responseId,
object: "chat.completion.chunk",
created,
model,
choices: [
{
index: 0,
delta: { role: "assistant", content: "" },
finish_reason: null
}
]
})}\n\n`
);
chunks.push(chatChunkSse({ id: responseId, created, model, delta: { role: "assistant", content: "" } }));
}
const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI);
+11
View File
@@ -1,3 +1,14 @@
export function sseChunk(data) {
return `data: ${JSON.stringify(data)}\n\n`;
}
// Build OpenAI chat.completion.chunk SSE frame. Key order: id, object, created, model, choices.
export function chatChunkSse({ id, created, model, delta, finishReason = null }) {
return sseChunk({
id,
object: "chat.completion.chunk",
created,
model,
choices: [{ index: 0, delta, finish_reason: finishReason }],
});
}
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "9Router web dashboard",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 20128",
"dev": "next dev --webpack --port 20127",
"build": "next build --webpack",
"start": "next start",
"dev:bun": "bun --bun next dev --webpack --port 20128",
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react";
import { Card } from "@/shared/components";
import { MEDIA_PROVIDER_KINDS, getProviderAlias, resolveProviderId } from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { Row, KIND_EXAMPLE_CONFIG } from "./exampleShared";
@@ -3,7 +3,7 @@
import { useState, useEffect } from "react";
import { Card } from "@/shared/components";
import { AI_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders";
import { getTtsVoicesForModel } from "open-sse/config/ttsModels.js";
@@ -0,0 +1,221 @@
"use client";
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { Badge, Button, Input, Modal, Select } from "@/shared/components";
const VARIANT_CONFIG = {
openai: {
title: "Add OpenAI Compatible",
type: "openai-compatible",
defaultBaseUrl: "https://api.openai.com/v1",
namePlaceholder: "OpenAI Compatible (Prod)",
prefixPlaceholder: "oc-prod",
baseUrlHint: "Use the base URL (ending in /v1) for your OpenAI-compatible API.",
modelIdPlaceholder: "e.g. gpt-4, claude-3-opus",
errorLabel: "OpenAI Compatible",
hasApiType: true,
},
anthropic: {
title: "Add Anthropic Compatible",
type: "anthropic-compatible",
defaultBaseUrl: "https://api.anthropic.com/v1",
namePlaceholder: "Anthropic Compatible (Prod)",
prefixPlaceholder: "ac-prod",
baseUrlHint: "Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages.",
modelIdPlaceholder: "e.g. claude-3-opus",
errorLabel: "Anthropic Compatible",
hasApiType: false,
},
};
const API_TYPE_OPTIONS = [
{ value: "chat", label: "Chat Completions" },
{ value: "responses", label: "Responses API" },
];
function AddCompatibleModal({ variant, isOpen, onClose, onCreated }) {
const config = VARIANT_CONFIG[variant];
const initialFormData = () => ({
name: "",
prefix: "",
...(config.hasApiType ? { apiType: "chat" } : {}),
baseUrl: config.defaultBaseUrl,
});
const [formData, setFormData] = useState(initialFormData);
const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [checkModelId, setCheckModelId] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
// openai: reset baseUrl when apiType changes; anthropic: reset checks when opened
useEffect(() => {
if (config.hasApiType) {
setFormData((prev) => ({ ...prev, baseUrl: config.defaultBaseUrl }));
} else if (isOpen) {
setValidationResult(null);
setCheckKey("");
setCheckModelId("");
}
}, [config.hasApiType ? formData.apiType : isOpen]);
const handleSubmit = async () => {
if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return;
setSubmitting(true);
try {
const res = await fetch("/api/provider-nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: formData.name,
prefix: formData.prefix,
...(config.hasApiType ? { apiType: formData.apiType } : {}),
baseUrl: formData.baseUrl,
type: config.type,
}),
});
const data = await res.json();
if (res.ok) {
onCreated(data.node);
setFormData(initialFormData());
setCheckKey("");
setValidationResult(null);
}
} catch (error) {
console.log(`Error creating ${config.errorLabel} node:`, error);
} finally {
setSubmitting(false);
}
};
const handleValidate = async () => {
setValidating(true);
try {
const res = await fetch("/api/provider-nodes/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: formData.baseUrl,
apiKey: checkKey,
type: config.type,
modelId: checkModelId.trim() || undefined,
}),
});
const data = await res.json();
setValidationResult(data);
} catch {
setValidationResult({ valid: false, error: "Network error" });
} finally {
setValidating(false);
}
};
const renderValidationResult = () => {
if (!validationResult) return null;
const { valid, error, method } = validationResult;
if (valid) {
return (
<>
<Badge variant="success">Valid</Badge>
{method === "chat" && (
<span className="text-sm text-text-muted">(via inference test)</span>
)}
</>
);
}
return (
<div className="flex flex-col gap-1">
<Badge variant="error">Invalid</Badge>
{error && <span className="text-sm text-red-500">{error}</span>}
</div>
);
};
return (
<Modal isOpen={isOpen} title={config.title} onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={config.namePlaceholder}
hint="Required. A friendly label for this node."
/>
<Input
label="Prefix"
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder={config.prefixPlaceholder}
hint="Required. Used as the provider prefix for model IDs."
/>
{config.hasApiType && (
<Select
label="API Type"
options={API_TYPE_OPTIONS}
value={formData.apiType}
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
/>
)}
<Input
label="Base URL"
value={formData.baseUrl}
onChange={(e) => setFormData({ ...formData, baseUrl: e.target.value })}
placeholder={config.defaultBaseUrl}
hint={config.baseUrlHint}
/>
<Input
label="API Key (for Check)"
type="password"
value={checkKey}
onChange={(e) => setCheckKey(e.target.value)}
/>
<Input
label="Model ID (optional)"
value={checkModelId}
onChange={(e) => setCheckModelId(e.target.value)}
placeholder={config.modelIdPlaceholder}
hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead."
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Button
onClick={handleValidate}
disabled={!checkKey || validating || !formData.baseUrl.trim()}
variant="secondary"
className="w-full sm:w-auto"
>
{validating ? "Checking..." : "Check"}
</Button>
{renderValidationResult()}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
onClick={handleSubmit}
fullWidth
disabled={
!formData.name.trim() ||
!formData.prefix.trim() ||
!formData.baseUrl.trim() ||
submitting
}
>
{submitting ? "Creating..." : "Create"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
</Modal>
);
}
AddCompatibleModal.propTypes = {
variant: PropTypes.oneOf(["openai", "anthropic"]).isRequired,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onCreated: PropTypes.func.isRequired,
};
export default AddCompatibleModal;
+5 -382
View File
@@ -7,9 +7,6 @@ import {
CardSkeleton,
Badge,
Button,
Input,
Modal,
Select,
Toggle,
} from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon";
@@ -26,6 +23,7 @@ import { getErrorCode, getRelativeTime } from "@/shared/utils";
import { useNotificationStore } from "@/store/notificationStore";
import { useHeaderSearchStore } from "@/store/headerSearchStore";
import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge";
import AddCompatibleModal from "./components/AddCompatibleModal";
function getStatusDisplay(connected, error, errorCode) {
const parts = [];
@@ -557,7 +555,8 @@ export default function ProvidersPage() {
</div>
</div> */}
<AddOpenAICompatibleModal
<AddCompatibleModal
variant="openai"
isOpen={showAddCompatibleModal}
onClose={() => setShowAddCompatibleModal(false)}
onCreated={(node) => {
@@ -565,7 +564,8 @@ export default function ProvidersPage() {
setShowAddCompatibleModal(false);
}}
/>
<AddAnthropicCompatibleModal
<AddCompatibleModal
variant="anthropic"
isOpen={showAddAnthropicCompatibleModal}
onClose={() => setShowAddAnthropicCompatibleModal(false)}
onCreated={(node) => {
@@ -854,383 +854,6 @@ ApiKeyProviderCard.propTypes = {
onToggle: PropTypes.func,
};
function AddOpenAICompatibleModal({ isOpen, onClose, onCreated }) {
const [formData, setFormData] = useState({
name: "",
prefix: "",
apiType: "chat",
baseUrl: "https://api.openai.com/v1",
});
const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [checkModelId, setCheckModelId] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const apiTypeOptions = [
{ value: "chat", label: "Chat Completions" },
{ value: "responses", label: "Responses API" },
];
useEffect(() => {
const defaultBaseUrl = "https://api.openai.com/v1";
setFormData((prev) => ({ ...prev, baseUrl: defaultBaseUrl }));
}, [formData.apiType]);
const handleSubmit = async () => {
if (
!formData.name.trim() ||
!formData.prefix.trim() ||
!formData.baseUrl.trim()
)
return;
setSubmitting(true);
try {
const res = await fetch("/api/provider-nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: formData.name,
prefix: formData.prefix,
apiType: formData.apiType,
baseUrl: formData.baseUrl,
type: "openai-compatible",
}),
});
const data = await res.json();
if (res.ok) {
onCreated(data.node);
setFormData({
name: "",
prefix: "",
apiType: "chat",
baseUrl: "https://api.openai.com/v1",
});
setCheckKey("");
setValidationResult(null);
}
} catch (error) {
console.log("Error creating OpenAI Compatible node:", error);
} finally {
setSubmitting(false);
}
};
const handleValidate = async () => {
setValidating(true);
try {
const res = await fetch("/api/provider-nodes/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: formData.baseUrl,
apiKey: checkKey,
type: "openai-compatible",
modelId: checkModelId.trim() || undefined,
}),
});
const data = await res.json();
setValidationResult(data);
} catch {
setValidationResult({ valid: false, error: "Network error" });
} finally {
setValidating(false);
}
};
// Helper to render validation result
const renderValidationResult = () => {
if (!validationResult) return null;
const { valid, error, method } = validationResult;
if (valid) {
return (
<>
<Badge variant="success">Valid</Badge>
{method === "chat" && (
<span className="text-sm text-text-muted">
(via inference test)
</span>
)}
</>
);
}
return (
<div className="flex flex-col gap-1">
<Badge variant="error">Invalid</Badge>
{error && <span className="text-sm text-red-500">{error}</span>}
</div>
);
};
return (
<Modal isOpen={isOpen} title="Add OpenAI Compatible" onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="OpenAI Compatible (Prod)"
hint="Required. A friendly label for this node."
/>
<Input
label="Prefix"
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder="oc-prod"
hint="Required. Used as the provider prefix for model IDs."
/>
<Select
label="API Type"
options={apiTypeOptions}
value={formData.apiType}
onChange={(e) =>
setFormData({ ...formData, apiType: e.target.value })
}
/>
<Input
label="Base URL"
value={formData.baseUrl}
onChange={(e) =>
setFormData({ ...formData, baseUrl: e.target.value })
}
placeholder="https://api.openai.com/v1"
hint="Use the base URL (ending in /v1) for your OpenAI-compatible API."
/>
<Input
label="API Key (for Check)"
type="password"
value={checkKey}
onChange={(e) => setCheckKey(e.target.value)}
/>
<Input
label="Model ID (optional)"
value={checkModelId}
onChange={(e) => setCheckModelId(e.target.value)}
placeholder="e.g. gpt-4, claude-3-opus"
hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead."
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Button
onClick={handleValidate}
disabled={!checkKey || validating || !formData.baseUrl.trim()}
variant="secondary"
className="w-full sm:w-auto"
>
{validating ? "Checking..." : "Check"}
</Button>
{renderValidationResult()}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
onClick={handleSubmit}
fullWidth
disabled={
!formData.name.trim() ||
!formData.prefix.trim() ||
!formData.baseUrl.trim() ||
submitting
}
>
{submitting ? "Creating..." : "Create"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
</Modal>
);
}
AddOpenAICompatibleModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onCreated: PropTypes.func.isRequired,
};
function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) {
const [formData, setFormData] = useState({
name: "",
prefix: "",
baseUrl: "https://api.anthropic.com/v1",
});
const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [checkModelId, setCheckModelId] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null); // { valid, error, method }
useEffect(() => {
if (isOpen) {
setValidationResult(null);
setCheckKey("");
setCheckModelId("");
}
}, [isOpen]);
const handleSubmit = async () => {
if (
!formData.name.trim() ||
!formData.prefix.trim() ||
!formData.baseUrl.trim()
)
return;
setSubmitting(true);
try {
const res = await fetch("/api/provider-nodes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: formData.name,
prefix: formData.prefix,
baseUrl: formData.baseUrl,
type: "anthropic-compatible",
}),
});
const data = await res.json();
if (res.ok) {
onCreated(data.node);
setFormData({
name: "",
prefix: "",
baseUrl: "https://api.anthropic.com/v1",
});
setCheckKey("");
setValidationResult(null);
}
} catch (error) {
console.log("Error creating Anthropic Compatible node:", error);
} finally {
setSubmitting(false);
}
};
const handleValidate = async () => {
setValidating(true);
try {
const res = await fetch("/api/provider-nodes/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: formData.baseUrl,
apiKey: checkKey,
type: "anthropic-compatible",
modelId: checkModelId.trim() || undefined,
}),
});
const data = await res.json();
setValidationResult(data);
} catch {
setValidationResult({ valid: false, error: "Network error" });
} finally {
setValidating(false);
}
};
// Helper to render validation result
const renderValidationResult = () => {
if (!validationResult) return null;
const { valid, error, method } = validationResult;
if (valid) {
return (
<>
<Badge variant="success">Valid</Badge>
{method === "chat" && (
<span className="text-sm text-text-muted">
(via inference test)
</span>
)}
</>
);
}
return (
<div className="flex flex-col gap-1">
<Badge variant="error">Invalid</Badge>
{error && <span className="text-sm text-red-500">{error}</span>}
</div>
);
};
return (
<Modal isOpen={isOpen} title="Add Anthropic Compatible" onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Anthropic Compatible (Prod)"
hint="Required. A friendly label for this node."
/>
<Input
label="Prefix"
value={formData.prefix}
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
placeholder="ac-prod"
hint="Required. Used as the provider prefix for model IDs."
/>
<Input
label="Base URL"
value={formData.baseUrl}
onChange={(e) =>
setFormData({ ...formData, baseUrl: e.target.value })
}
placeholder="https://api.anthropic.com/v1"
hint="Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages."
/>
<Input
label="API Key (for Check)"
type="password"
value={checkKey}
onChange={(e) => setCheckKey(e.target.value)}
/>
<Input
label="Model ID (optional)"
value={checkModelId}
onChange={(e) => setCheckModelId(e.target.value)}
placeholder="e.g. claude-3-opus"
hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead."
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Button
onClick={handleValidate}
disabled={!checkKey || validating || !formData.baseUrl.trim()}
variant="secondary"
className="w-full sm:w-auto"
>
{validating ? "Checking..." : "Check"}
</Button>
{renderValidationResult()}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
onClick={handleSubmit}
fullWidth
disabled={
!formData.name.trim() ||
!formData.prefix.trim() ||
!formData.baseUrl.trim() ||
submitting
}
>
{submitting ? "Creating..." : "Create"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
</Modal>
);
}
AddAnthropicCompatibleModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onCreated: PropTypes.func.isRequired,
};
function ProviderTestResultsView({ results }) {
if (results.error && !results.results) {
return (
+11 -3
View File
@@ -180,13 +180,21 @@ export default function ModelSelectModal({
let combined = aliasModels;
if (kindFilter && TYPED_KINDS.has(kindFilter)) {
combined = getModelsByProviderId(providerId)
.filter((m) => mKind(m) === kindFilter)
.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, kind: mKind(m) }));
.filter((m) => getModelKind(m) === kindFilter)
.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, kind: getModelKind(m) }));
// Fallback: provider-as-model when no hardcoded models match (tts/image/webFetch only)
if (combined.length === 0 && ALLOW_PROVIDER_FALLBACK_KINDS.has(kindFilter)) {
const supports = (providerInfo.serviceKinds || ["llm"]).includes(kindFilter);
if (supports) combined = [{ id: providerId, name: providerInfo.name, value: alias }];
}
} else {
// LLM/null kind: merge hardcoded models (e.g. mimo-free → mimo-auto) with aliases
const seen = new Set(aliasModels.map((m) => m.value));
const hardcoded = getModelsByProviderId(providerId)
.filter((m) => !getModelKind(m) || getModelKind(m) === "llm")
.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, kind: getModelKind(m) }))
.filter((m) => !seen.has(m.value));
combined = [...aliasModels, ...hardcoded];
}
if (combined.length > 0) {
@@ -262,7 +270,7 @@ export default function ModelSelectModal({
.map((m) => ({ id: m.id, name: m.name || m.id, value: `${alias}/${m.id}`, isCustom: true }));
const merged = [
...hardcodedModels.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, kind: mKind(m) })),
...hardcodedModels.map((m) => ({ id: m.id, name: m.name, value: `${alias}/${m.id}`, kind: getModelKind(m) })),
...customAliasModels,
...customRegisteredModels,
];
File diff suppressed because one or more lines are too long