Merge origin/master (v0.5.75) into gitea/new_feature
Resolve conflicts: - package.json / cli/package.json: take 0.5.75 - .gitignore: union both sides (upstream 9router-*/temp files + local state dirs) - CHANGELOG.md: keep both blocks, v0.5.75 above v0.5.70 - nonStreamingHandler.js: merge imports (unwrapClineEnvelope + tokensForDetail/shouldPersistRequestDetail); drop dead appendRequestLog - providers/[id]/page.js: union useState blocks (compatible-model states + importingClineModels) Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
@@ -28,7 +28,7 @@ import { compressWithPxpipe } from "../rtk/pxpipe.js";
|
||||
import { getCapabilitiesForModel } from "../providers/capabilities.js";
|
||||
import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
|
||||
import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
|
||||
import { defaultClaudeToolType } from "../translator/concerns/toolCall.js";
|
||||
import { defaultClaudeToolType, shouldDefaultClaudeToolType } from "../translator/concerns/toolCall.js";
|
||||
import { resolveSessionId } from "../utils/sessionManager.js";
|
||||
import { maybeRejectEarlyStreamError } from "../utils/streamErrorPeek.js";
|
||||
|
||||
@@ -246,7 +246,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
|
||||
// Claude tool schema requires `type` to be explicitly set; strict gateways (e.g., MiniMax)
|
||||
// reject legacy payloads that omit it with HTTP 400. Default to "custom" when missing.
|
||||
if (finalFormat === FORMATS.CLAUDE && Array.isArray(translatedBody.tools)) {
|
||||
// Provider-scoped via quirks (shouldDefaultClaudeToolType): only gateways that declare
|
||||
// requireClaudeToolType get the explicit type. Applying it unconditionally breaks
|
||||
// Claude-format endpoints that only accept the legacy typeless tool shape — DeepSeek's
|
||||
// Anthropic-compatible endpoint 400s with "unknown variant `custom`" (#3905).
|
||||
if (shouldDefaultClaudeToolType(provider, finalFormat, translatedBody.tools, PROVIDERS)) {
|
||||
translatedBody.tools = defaultClaudeToolType(translatedBody.tools);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTrackin
|
||||
import { createErrorResult } from "../../utils/error.js";
|
||||
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
||||
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
|
||||
import { unwrapClineEnvelope } from "../../shared/clineEnvelope.js";
|
||||
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine, tokensForDetail, shouldPersistRequestDetail } from "./requestDetail.js";
|
||||
import { saveRequestDetail } from "@/lib/usageDb.js";
|
||||
import { matchStreamErrorPatterns } from "../../utils/streamErrorPatterns.js";
|
||||
@@ -305,6 +306,11 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap before any consumer reads choices/usage so non-stream clients get a
|
||||
// bare OpenAI body and usage tracking sees data.usage. No-op unless the
|
||||
// provider opts in via transport.quirks.clineEnvelope.
|
||||
responseBody = unwrapClineEnvelope(responseBody, provider);
|
||||
|
||||
reqLogger.logProviderResponse(providerResponse.status, providerResponse.statusText, providerResponse.headers, responseBody);
|
||||
if (onRequestSuccess) {
|
||||
Promise.resolve()
|
||||
|
||||
@@ -2,13 +2,21 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { nowSec } from "./_base.js";
|
||||
import { PROVIDERS } from "../../config/providers.js";
|
||||
import { CODEX_CLI_VERSION } from "../../config/appConstants.js";
|
||||
|
||||
const CODEX_RESPONSES_URL = PROVIDERS["codex"].baseUrl;
|
||||
const CODEX_USER_AGENT = "codex_cli_rs/0.136.0";
|
||||
const CODEX_VERSION = "0.136.0";
|
||||
const CODEX_USER_AGENT = `codex_cli_rs/${CODEX_CLI_VERSION}`;
|
||||
const CODEX_ORIGINATOR = "codex_cli_rs";
|
||||
const CODEX_MODEL_SUFFIX = "-image";
|
||||
const CODEX_REF_DETAIL = "high";
|
||||
const CODEX_IMAGES_MAIN_MODEL = "gpt-5.5";
|
||||
const CODEX_TOOL_IMAGE_MODELS = new Set([
|
||||
"gpt-image-1.5",
|
||||
"gpt-image-2",
|
||||
"gpt-image-2.5",
|
||||
"gpt-image-2.5-flare",
|
||||
"gpt-image-2.5-sunburst",
|
||||
]);
|
||||
|
||||
function decodeAccountId(idToken) {
|
||||
try {
|
||||
@@ -27,6 +35,13 @@ function stripImageSuffix(model) {
|
||||
return model.endsWith(CODEX_MODEL_SUFFIX) ? model.slice(0, -CODEX_MODEL_SUFFIX.length) : model;
|
||||
}
|
||||
|
||||
function resolveCodexImageModels(model) {
|
||||
if (CODEX_TOOL_IMAGE_MODELS.has(model)) {
|
||||
return { responsesModel: CODEX_IMAGES_MAIN_MODEL, toolModel: model };
|
||||
}
|
||||
return { responsesModel: stripImageSuffix(model), toolModel: null };
|
||||
}
|
||||
|
||||
function toDataUrl(input) {
|
||||
if (!input || typeof input !== "string") return null;
|
||||
if (/^data:image\//i.test(input) || /^https?:\/\//i.test(input)) return input;
|
||||
@@ -157,7 +172,7 @@ export default {
|
||||
"originator": CODEX_ORIGINATOR,
|
||||
"session_id": randomUUID(),
|
||||
"user-agent": CODEX_USER_AGENT,
|
||||
"version": CODEX_VERSION,
|
||||
"version": CODEX_CLI_VERSION,
|
||||
"x-client-request-id": randomUUID(),
|
||||
};
|
||||
},
|
||||
@@ -167,21 +182,26 @@ export default {
|
||||
const single = toDataUrl(body.image);
|
||||
if (single) refs.push(single);
|
||||
const detail = body.image_detail || CODEX_REF_DETAIL;
|
||||
const { responsesModel, toolModel } = resolveCodexImageModels(model);
|
||||
const imgTool = { type: "image_generation", output_format: (body.output_format || "png").toLowerCase() };
|
||||
if (toolModel) {
|
||||
imgTool.action = refs.length > 0 ? "edit" : "generate";
|
||||
imgTool.model = toolModel;
|
||||
}
|
||||
if (body.size && body.size !== "") imgTool.size = body.size;
|
||||
if (body.quality && body.quality !== "") imgTool.quality = body.quality;
|
||||
if (body.background && body.background !== "") imgTool.background = body.background;
|
||||
return {
|
||||
model: stripImageSuffix(model),
|
||||
model: responsesModel,
|
||||
instructions: "",
|
||||
input: [{ type: "message", role: "user", content: buildContent(body.prompt, refs, detail) }],
|
||||
tools: [imgTool],
|
||||
tool_choice: "auto",
|
||||
tool_choice: toolModel ? { type: "image_generation" } : "auto",
|
||||
parallel_tool_calls: false,
|
||||
prompt_cache_key: randomUUID(),
|
||||
stream: true,
|
||||
store: false,
|
||||
reasoning: null,
|
||||
reasoning: toolModel ? { effort: "medium", summary: "auto" } : null,
|
||||
};
|
||||
},
|
||||
// Custom: codex parses SSE → either pipe to client or collect b64
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createErrorResult } from "../utils/error.js";
|
||||
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||
import { refreshTokenByProvider } from "../services/tokenRefresh.js";
|
||||
import { PROVIDER_MEDIA } from "../providers/index.js";
|
||||
import { getVideoAdapter } from "./videoProviders/index.js";
|
||||
|
||||
// Upstream fetch deadline for video job submission/polling (the job itself is
|
||||
// async upstream — this only bounds the HTTP round-trip, not video rendering).
|
||||
@@ -94,21 +95,49 @@ export async function handleVideoProxyCore({
|
||||
return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Unknown video action: ${action}`);
|
||||
}
|
||||
|
||||
const method = requestId ? "GET" : "POST";
|
||||
const url = buildUpstreamUrl(config, action, requestId);
|
||||
const adapter = getVideoAdapter(provider);
|
||||
const fetchSignal = combineSignals(signal, timeoutMs);
|
||||
|
||||
const doFetch = (token) =>
|
||||
fetch(url, {
|
||||
// Default (xAI shape) request plan; adapters override URL/method/headers/body.
|
||||
const defaultPlan = () => {
|
||||
const method = requestId ? "GET" : "POST";
|
||||
return {
|
||||
method,
|
||||
headers: buildHeaders({ token, contentType: method === "POST" ? contentType : null, idempotencyKey: method === "POST" ? idempotencyKey : null }),
|
||||
url: buildUpstreamUrl(config, action, requestId),
|
||||
headers: buildHeaders({
|
||||
token: credentials?.accessToken || credentials?.apiKey,
|
||||
contentType: method === "POST" ? contentType : null,
|
||||
idempotencyKey: method === "POST" ? idempotencyKey : null,
|
||||
}),
|
||||
body: method === "POST" ? rawBody : undefined,
|
||||
signal: fetchSignal,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// Rebuilt per attempt so the auth retry below picks up the refreshed token.
|
||||
const doFetch = async () => {
|
||||
const plan = adapter
|
||||
? await adapter.buildRequest({
|
||||
config, action, requestId, rawBody, contentType, idempotencyKey, credentials, log,
|
||||
token: credentials?.accessToken || credentials?.apiKey,
|
||||
})
|
||||
: defaultPlan();
|
||||
if (plan.error) return { planError: plan.error };
|
||||
return {
|
||||
response: await fetch(plan.url, {
|
||||
method: plan.method,
|
||||
headers: plan.headers,
|
||||
body: plan.body,
|
||||
signal: fetchSignal,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const method = requestId ? "GET" : "POST";
|
||||
let upstream;
|
||||
try {
|
||||
upstream = await doFetch(credentials?.accessToken || credentials?.apiKey);
|
||||
const first = await doFetch();
|
||||
if (first.planError) return createErrorResult(HTTP_STATUS.BAD_REQUEST, `[${provider}] ${first.planError}`);
|
||||
upstream = first.response;
|
||||
} catch (error) {
|
||||
if (error?.name === "AbortError" || error?.name === "TimeoutError") {
|
||||
return createErrorResult(HTTP_STATUS.REQUEST_TIMEOUT, `[${provider}] video ${method} aborted: ${error.message}`);
|
||||
@@ -136,7 +165,9 @@ export async function handleVideoProxyCore({
|
||||
await upstream.body?.cancel?.();
|
||||
} catch { /* noop */ }
|
||||
try {
|
||||
upstream = await doFetch(credentials.accessToken || credentials.apiKey);
|
||||
const retry = await doFetch();
|
||||
if (retry.planError) return createErrorResult(HTTP_STATUS.BAD_REQUEST, `[${provider}] ${retry.planError}`);
|
||||
upstream = retry.response;
|
||||
} catch (error) {
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, sanitizeSecrets(`[${provider}] video retry after refresh failed: ${error.message}`, credentials));
|
||||
}
|
||||
@@ -152,13 +183,25 @@ export async function handleVideoProxyCore({
|
||||
return createErrorResult(upstream.status, `[${provider}] ${message.slice(0, 2000)}`);
|
||||
}
|
||||
|
||||
// Success: pass the upstream JSON through untouched (request_id / status / video.url).
|
||||
// Success: pass the upstream JSON through untouched (request_id / status / video.url),
|
||||
// unless the adapter maps a provider-native shape onto it (Vertex operations).
|
||||
let outBody = bodyText;
|
||||
let outType = upstream.headers.get("content-type") || "application/json";
|
||||
if (adapter?.transformResponse) {
|
||||
try {
|
||||
outBody = JSON.stringify(adapter.transformResponse(JSON.parse(bodyText)));
|
||||
outType = "application/json";
|
||||
} catch {
|
||||
// Non-JSON or unexpected shape — fall back to the raw upstream body.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
response: new Response(bodyText, {
|
||||
response: new Response(outBody, {
|
||||
status: upstream.status,
|
||||
headers: {
|
||||
"Content-Type": upstream.headers.get("content-type") || "application/json",
|
||||
"Content-Type": outType,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
}),
|
||||
|
||||
13
open-sse/handlers/videoProviders/index.js
Normal file
13
open-sse/handlers/videoProviders/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Video provider adapters.
|
||||
//
|
||||
// Default (no adapter) = xAI shape: raw body forwarded to {baseUrl}/{action},
|
||||
// polled at {baseUrl}/{id}, upstream JSON passed through verbatim.
|
||||
// A provider only needs an adapter when its wire format differs from that.
|
||||
import openrouter from "./openrouter.js";
|
||||
import vertex from "./vertex.js";
|
||||
|
||||
const ADAPTERS = { openrouter, vertex };
|
||||
|
||||
export function getVideoAdapter(provider) {
|
||||
return ADAPTERS[provider] || null;
|
||||
}
|
||||
39
open-sse/handlers/videoProviders/openrouter.js
Normal file
39
open-sse/handlers/videoProviders/openrouter.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// OpenRouter video jobs — https://openrouter.ai/docs/api/api-reference/videos
|
||||
//
|
||||
// Same async shape as xAI (POST → { id, status }, GET → status/unsigned_urls),
|
||||
// two differences only: creation POSTs to the collection root (no `/generations`
|
||||
// suffix) and the account headers come from the registry entry.
|
||||
// Response bodies are passed through verbatim.
|
||||
|
||||
// ponytail: generations only — OpenRouter has no edits/extensions endpoint today.
|
||||
const SUPPORTED_ACTIONS = new Set(["generations"]);
|
||||
|
||||
function headers(config, token) {
|
||||
return {
|
||||
Accept: "application/json",
|
||||
...(config.headers || {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
buildRequest({ config, action, requestId, rawBody, contentType, token }) {
|
||||
const base = config.baseUrl.replace(/\/$/, "");
|
||||
|
||||
if (requestId) {
|
||||
return { method: "GET", url: `${base}/${encodeURIComponent(requestId)}`, headers: headers(config, token) };
|
||||
}
|
||||
if (!SUPPORTED_ACTIONS.has(action)) {
|
||||
return { error: `OpenRouter video supports 'generations' only (got '${action}')` };
|
||||
}
|
||||
if (contentType && !contentType.includes("application/json")) {
|
||||
return { error: "OpenRouter video requires an application/json body" };
|
||||
}
|
||||
return {
|
||||
method: "POST",
|
||||
url: base,
|
||||
headers: { ...headers(config, token), "Content-Type": "application/json" },
|
||||
body: rawBody,
|
||||
};
|
||||
},
|
||||
};
|
||||
159
open-sse/handlers/videoProviders/vertex.js
Normal file
159
open-sse/handlers/videoProviders/vertex.js
Normal file
@@ -0,0 +1,159 @@
|
||||
// Vertex AI (Veo) video jobs.
|
||||
//
|
||||
// Vertex does NOT speak the OpenAI-ish /v1/videos shape, so unlike OpenRouter
|
||||
// this adapter translates both directions:
|
||||
// create → POST {model}:predictLongRunning { instances[], parameters{} } → { name }
|
||||
// poll → POST {model}:fetchPredictOperation { operationName } → { done, response }
|
||||
// Docs: https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/veo-video-generation
|
||||
//
|
||||
// The operation name is a resource path (contains "/"), so it is base64url-encoded
|
||||
// into the job id returned to the client — GET /v1/videos/{id} stays a flat path.
|
||||
import { parseVertexSaJson, refreshVertexToken } from "../../services/tokenRefresh.js";
|
||||
|
||||
const DEFAULT_LOCATION = "us-central1";
|
||||
|
||||
const encodeJobId = (name) => Buffer.from(name, "utf8").toString("base64url");
|
||||
|
||||
// Operation name shape: projects/{p}/locations/{l}/publishers/{pub}/models/{m}/operations/{op}.
|
||||
// Anchored and single-segment-per-field so a decoded path can never carry `..` or a
|
||||
// host-changing prefix into the request URL.
|
||||
const OPERATION_NAME_RE = /^projects\/[^/]+\/locations\/[^/]+\/publishers\/[^/]+\/models\/[^/]+\/operations\/[^/]+$/;
|
||||
|
||||
function modelPathOf(operationName) {
|
||||
return operationName.slice(0, operationName.indexOf("/operations/"));
|
||||
}
|
||||
|
||||
function decodeJobId(id) {
|
||||
const raw = String(id ?? "");
|
||||
// Buffer.from(x, "base64url") silently drops invalid characters instead of
|
||||
// throwing, so only ids that re-encode byte-for-byte are accepted.
|
||||
if (!raw || raw.length > 1024 || !/^[A-Za-z0-9_-]+$/.test(raw)) return null;
|
||||
const decoded = Buffer.from(raw, "base64url").toString("utf8");
|
||||
if (Buffer.from(decoded, "utf8").toString("base64url") !== raw) return null;
|
||||
return OPERATION_NAME_RE.test(decoded) ? decoded : null;
|
||||
}
|
||||
|
||||
async function resolveAuth(credentials, log) {
|
||||
const saJson = parseVertexSaJson(credentials?.apiKey);
|
||||
const projectId =
|
||||
saJson?.project_id ||
|
||||
credentials?.projectId ||
|
||||
credentials?.providerSpecificData?.projectId;
|
||||
const location = credentials?.providerSpecificData?.location || DEFAULT_LOCATION;
|
||||
|
||||
if (!projectId) {
|
||||
return { error: "Vertex video requires a project_id — use Service Account JSON or set providerSpecificData.projectId" };
|
||||
}
|
||||
|
||||
let token = credentials?.accessToken;
|
||||
if (saJson) {
|
||||
const minted = await refreshVertexToken(saJson, log);
|
||||
if (!minted?.accessToken) return { error: "Vertex video: failed to mint access token from service account JSON" };
|
||||
token = minted.accessToken;
|
||||
}
|
||||
if (!token) return { error: "Vertex video requires Service Account JSON or an OAuth access token (raw API keys are not supported)" };
|
||||
|
||||
return { token, projectId, location };
|
||||
}
|
||||
|
||||
/** OpenAI-ish video body → Vertex predictLongRunning body. */
|
||||
function toVertexBody(body) {
|
||||
const instance = { prompt: body.prompt };
|
||||
// Image-to-video: accept the Vertex-native shape or a bare data URL / base64 string.
|
||||
const image = body.image ?? body.image_url;
|
||||
if (image && typeof image === "object") {
|
||||
instance.image = image;
|
||||
} else if (typeof image === "string") {
|
||||
const match = image.match(/^data:([^;]+);base64,(.*)$/s);
|
||||
instance.image = match
|
||||
? { bytesBase64Encoded: match[2], mimeType: match[1] }
|
||||
: { gcsUri: image };
|
||||
}
|
||||
if (body.video && typeof body.video === "object") instance.video = body.video;
|
||||
|
||||
const parameters = {};
|
||||
if (body.n != null) parameters.sampleCount = Number(body.n);
|
||||
if (body.duration != null) parameters.durationSeconds = Number(body.duration);
|
||||
if (body.aspect_ratio) parameters.aspectRatio = body.aspect_ratio;
|
||||
if (body.resolution) parameters.resolution = body.resolution;
|
||||
if (body.seed != null) parameters.seed = body.seed;
|
||||
if (body.negative_prompt) parameters.negativePrompt = body.negative_prompt;
|
||||
// Without storageUri Vertex returns inline base64 bytes; a GCS bucket keeps
|
||||
// the poll response small and is what production callers want.
|
||||
if (body.storage_uri) parameters.storageUri = body.storage_uri;
|
||||
if (body.generate_audio != null) parameters.generateAudio = !!body.generate_audio;
|
||||
|
||||
return { instances: [instance], ...(Object.keys(parameters).length ? { parameters } : {}) };
|
||||
}
|
||||
|
||||
/** Vertex operation → the async-job shape 9Router clients already poll for. */
|
||||
function fromVertexOperation(json) {
|
||||
if (!json?.name) return json;
|
||||
const id = encodeJobId(json.name);
|
||||
if (json.error) {
|
||||
return { id, request_id: id, status: "failed", error: json.error };
|
||||
}
|
||||
if (!json.done) {
|
||||
return { id, request_id: id, status: "pending" };
|
||||
}
|
||||
const samples =
|
||||
json.response?.videos ||
|
||||
json.response?.generateVideoResponse?.generatedSamples ||
|
||||
[];
|
||||
const videos = samples.map((s) => ({
|
||||
url: s.gcsUri || s.video?.uri || s.uri || null,
|
||||
b64_json: s.bytesBase64Encoded || s.video?.bytesBase64Encoded || null,
|
||||
mime_type: s.mimeType || s.video?.mimeType || "video/mp4",
|
||||
}));
|
||||
return { id, request_id: id, status: "completed", video: videos[0] || null, videos };
|
||||
}
|
||||
|
||||
export default {
|
||||
async buildRequest({ config, action, requestId, rawBody, contentType, credentials, log }) {
|
||||
if (contentType && !contentType.includes("application/json")) {
|
||||
return { error: "Vertex video requires an application/json body" };
|
||||
}
|
||||
|
||||
const auth = await resolveAuth(credentials, log);
|
||||
if (auth.error) return { error: auth.error };
|
||||
const { token, projectId, location } = auth;
|
||||
const base = (config.baseUrl || "https://aiplatform.googleapis.com").replace(/\/$/, "");
|
||||
const headers = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}` };
|
||||
|
||||
if (requestId) {
|
||||
const operationName = decodeJobId(requestId);
|
||||
if (!operationName) return { error: "Invalid Vertex video job id" };
|
||||
return {
|
||||
method: "POST",
|
||||
url: `${base}/v1/${modelPathOf(operationName)}:fetchPredictOperation`,
|
||||
headers,
|
||||
body: JSON.stringify({ operationName }),
|
||||
};
|
||||
}
|
||||
|
||||
if (action !== "generations") {
|
||||
// ponytail: Veo extend/edit go through generations with `video`/`image` in the body.
|
||||
return { error: `Vertex video supports 'generations' only (got '${action}')` };
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(typeof rawBody === "string" ? rawBody : rawBody.toString("utf8"));
|
||||
} catch {
|
||||
return { error: "Invalid JSON body" };
|
||||
}
|
||||
if (!body.model) return { error: "Vertex video requires a model (e.g. vertex/veo-3.1-generate-preview)" };
|
||||
// Plain model id only — a path segment carrying "/" or ".." would rewrite the URL.
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(body.model)) return { error: "Invalid Vertex video model id" };
|
||||
if (!body.prompt && !body.image && !body.image_url) return { error: "Vertex video requires a prompt or an image" };
|
||||
|
||||
return {
|
||||
method: "POST",
|
||||
url: `${base}/v1/projects/${projectId}/locations/${location}/publishers/google/models/${body.model}:predictLongRunning`,
|
||||
headers,
|
||||
body: JSON.stringify(toVertexBody(body)),
|
||||
};
|
||||
},
|
||||
|
||||
transformResponse: fromVertexOperation,
|
||||
};
|
||||
Reference in New Issue
Block a user