mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
feat: auto-detect single-endpoint transport from runtime info response
Instead of requiring users to explicitly set transport: "single", the
client now auto-detects the transport mode by trying REST (GET /info)
first and falling back to single-endpoint (POST with { method: "info" })
if the REST probe returns 404/405 or fails. The explicit flag is kept
for backward compatibility.
https://claude.ai/code/session_016xtYN15TY2BaBviryTcVyY
This commit is contained in:
@@ -39,7 +39,7 @@ export class CopilotKit {
|
||||
readonly runtimeConnectionStatus = this.#runtimeConnectionStatus.asReadonly();
|
||||
readonly #runtimeUrl = signal<string | undefined>(undefined);
|
||||
readonly runtimeUrl = this.#runtimeUrl.asReadonly();
|
||||
readonly #runtimeTransport = signal<CopilotRuntimeTransport>("rest");
|
||||
readonly #runtimeTransport = signal<CopilotRuntimeTransport>("auto");
|
||||
readonly runtimeTransport = this.#runtimeTransport.asReadonly();
|
||||
readonly #headers = signal<Record<string, string>>({});
|
||||
readonly headers = this.#headers.asReadonly();
|
||||
|
||||
@@ -304,6 +304,200 @@ function createSseResponse(): Response {
|
||||
});
|
||||
}
|
||||
|
||||
describe("Auto-detect transport from runtime info response", () => {
|
||||
const originalFetch = global.fetch;
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
const infoResponse = {
|
||||
version: "1.0.0",
|
||||
agents: {
|
||||
remote: {
|
||||
description: "Remote agent",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { window?: unknown }).window = {};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
global.fetch = originalFetch;
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-detects REST transport when GET /info succeeds", async () => {
|
||||
const runtimeUrl = "https://runtime.example/rest-auto";
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(infoResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
// @ts-expect-error - override in test environment
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// No runtimeTransport specified — defaults to "auto"
|
||||
const core = new CopilotKitCore({
|
||||
runtimeUrl,
|
||||
headers: { Authorization: "Bearer token" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should have tried REST first (GET /info)
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(`${runtimeUrl}/info`);
|
||||
expect(init.method ?? "GET").toBe("GET");
|
||||
|
||||
// Remote agent should be registered
|
||||
const remoteAgent = core.getAgent("remote");
|
||||
expect(remoteAgent).toBeDefined();
|
||||
expect(remoteAgent?.agentId).toBe("remote");
|
||||
|
||||
// Transport should have been resolved to "rest"
|
||||
expect(core.runtimeTransport).toBe("rest");
|
||||
});
|
||||
|
||||
it("auto-detects single-endpoint transport when GET /info fails", async () => {
|
||||
const runtimeUrl = "https://runtime.example/single-auto";
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation((url: string, init?: RequestInit) => {
|
||||
// REST attempt: GET /info → 404
|
||||
if (
|
||||
typeof url === "string" &&
|
||||
url.endsWith("/info") &&
|
||||
(!init?.method || init.method === "GET")
|
||||
) {
|
||||
return Promise.resolve(new Response("Not Found", { status: 404 }));
|
||||
}
|
||||
// Single-endpoint attempt: POST with { method: "info" }
|
||||
if (init?.method === "POST") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(infoResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.reject(new Error("Unexpected fetch call"));
|
||||
});
|
||||
// @ts-expect-error - override in test environment
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// No runtimeTransport specified — defaults to "auto"
|
||||
const core = new CopilotKitCore({
|
||||
runtimeUrl,
|
||||
headers: { Authorization: "Bearer token" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// First call should be REST attempt (GET /info)
|
||||
const [url1] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url1).toBe(`${runtimeUrl}/info`);
|
||||
|
||||
// Second call should be single-endpoint attempt (POST with { method: "info" })
|
||||
const [url2, init2] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||
expect(url2).toBe(runtimeUrl);
|
||||
expect(init2.method).toBe("POST");
|
||||
const body = JSON.parse(init2.body as string);
|
||||
expect(body).toEqual({ method: "info" });
|
||||
|
||||
// Remote agent should be registered
|
||||
const remoteAgent = core.getAgent("remote");
|
||||
expect(remoteAgent).toBeDefined();
|
||||
expect(remoteAgent?.agentId).toBe("remote");
|
||||
|
||||
// Transport should have been resolved to "single"
|
||||
expect(core.runtimeTransport).toBe("single");
|
||||
});
|
||||
|
||||
it("explicit transport='single' flag still works without auto-detection", async () => {
|
||||
const runtimeUrl = "https://runtime.example/explicit-single";
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(infoResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
// @ts-expect-error - override in test environment
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const core = new CopilotKitCore({
|
||||
runtimeUrl,
|
||||
runtimeTransport: "single",
|
||||
headers: { Authorization: "Bearer token" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should have gone directly to single-endpoint (POST with { method: "info" })
|
||||
// without trying REST first
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(runtimeUrl);
|
||||
expect(init.method).toBe("POST");
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body).toEqual({ method: "info" });
|
||||
|
||||
// Remote agent should be registered
|
||||
const remoteAgent = core.getAgent("remote");
|
||||
expect(remoteAgent).toBeDefined();
|
||||
expect(remoteAgent?.agentId).toBe("remote");
|
||||
|
||||
// Transport stays "single"
|
||||
expect(core.runtimeTransport).toBe("single");
|
||||
});
|
||||
|
||||
it("explicit transport='rest' flag still works without auto-detection", async () => {
|
||||
const runtimeUrl = "https://runtime.example/explicit-rest";
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(infoResponse), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
// @ts-expect-error - override in test environment
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const core = new CopilotKitCore({
|
||||
runtimeUrl,
|
||||
runtimeTransport: "rest",
|
||||
headers: { Authorization: "Bearer token" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should have gone directly to REST (GET /info) without trying single
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(`${runtimeUrl}/info`);
|
||||
expect(init.method ?? "GET").toBe("GET");
|
||||
|
||||
// Remote agent should be registered
|
||||
const remoteAgent = core.getAgent("remote");
|
||||
expect(remoteAgent).toBeDefined();
|
||||
|
||||
// Transport stays "rest"
|
||||
expect(core.runtimeTransport).toBe("rest");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentRegistry runtime info requests", () => {
|
||||
const originalFetch = global.fetch;
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
@@ -95,7 +95,7 @@ export class ProxiedCopilotRuntimeAgent extends HttpAgent {
|
||||
const normalizedRuntimeUrl = config.runtimeUrl
|
||||
? config.runtimeUrl.replace(/\/$/, "")
|
||||
: undefined;
|
||||
const transport = config.transport ?? "rest";
|
||||
const transport = config.transport ?? "auto";
|
||||
const runUrl =
|
||||
transport === "single"
|
||||
? (normalizedRuntimeUrl ?? config.runtimeUrl ?? "")
|
||||
@@ -403,6 +403,10 @@ export class ProxiedCopilotRuntimeAgent extends HttpAgent {
|
||||
...this.headers,
|
||||
};
|
||||
|
||||
if (this.transport === "auto") {
|
||||
return this.fetchRuntimeInfoAutoDetect(headers);
|
||||
}
|
||||
|
||||
let init: RequestInit;
|
||||
let url: string;
|
||||
|
||||
@@ -433,6 +437,45 @@ export class ProxiedCopilotRuntimeAgent extends HttpAgent {
|
||||
return (await response.json()) as RuntimeInfo;
|
||||
}
|
||||
|
||||
private async fetchRuntimeInfoAutoDetect(
|
||||
headers: Record<string, string>,
|
||||
): Promise<RuntimeInfo> {
|
||||
// Try REST first (GET /info)
|
||||
try {
|
||||
const response = await fetch(`${this.runtimeUrl}/info`, {
|
||||
headers: { ...headers },
|
||||
...(this.credentials ? { credentials: this.credentials } : {}),
|
||||
});
|
||||
const status = "status" in response ? (response as Response).status : 200;
|
||||
if (status !== 404 && status !== 405) {
|
||||
this.transport = "rest";
|
||||
return (await response.json()) as RuntimeInfo;
|
||||
}
|
||||
} catch {
|
||||
// REST failed — fall through to single-endpoint attempt
|
||||
}
|
||||
|
||||
// Try single-endpoint (POST with { method: "info" })
|
||||
const singleHeaders = { ...headers };
|
||||
if (!singleHeaders["Content-Type"]) {
|
||||
singleHeaders["Content-Type"] = "application/json";
|
||||
}
|
||||
const response = await fetch(this.runtimeUrl!, {
|
||||
method: "POST",
|
||||
headers: singleHeaders,
|
||||
body: JSON.stringify({ method: "info" }),
|
||||
...(this.credentials ? { credentials: this.credentials } : {}),
|
||||
});
|
||||
if ("ok" in response && !response.ok) {
|
||||
throw new Error(
|
||||
`Runtime info request failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
this.transport = "single";
|
||||
this.singleEndpointUrl = this.runtimeUrl;
|
||||
return (await response.json()) as RuntimeInfo;
|
||||
}
|
||||
|
||||
private createSingleRouteRequestInit(
|
||||
input: RunAgentInput,
|
||||
method: string,
|
||||
|
||||
@@ -36,7 +36,7 @@ export class AgentRegistry {
|
||||
private _runtimeVersion?: string;
|
||||
private _runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus =
|
||||
CopilotKitCoreRuntimeConnectionStatus.Disconnected;
|
||||
private _runtimeTransport: CopilotRuntimeTransport = "rest";
|
||||
private _runtimeTransport: CopilotRuntimeTransport = "auto";
|
||||
private _audioFileTranscriptionEnabled: boolean = false;
|
||||
private _runtimeMode: RuntimeMode = RUNTIME_MODE_SSE;
|
||||
private _intelligence?: IntelligenceRuntimeInfo;
|
||||
@@ -347,23 +347,14 @@ export class AgentRegistry {
|
||||
};
|
||||
|
||||
if (this._runtimeTransport === "single") {
|
||||
if (!headers["Content-Type"]) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
const response = await fetch(this.runtimeUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ method: "info" }),
|
||||
...(credentials ? { credentials } : {}),
|
||||
});
|
||||
if ("ok" in response && !(response as Response).ok) {
|
||||
throw new Error(
|
||||
`Runtime info request failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as RuntimeInfo;
|
||||
return this.fetchRuntimeInfoSingle(headers, credentials);
|
||||
}
|
||||
|
||||
if (this._runtimeTransport === "auto") {
|
||||
return this.fetchRuntimeInfoAutoDetect(headers, credentials);
|
||||
}
|
||||
|
||||
// REST transport
|
||||
const response = await fetch(`${this.runtimeUrl}/info`, {
|
||||
headers,
|
||||
...(credentials ? { credentials } : {}),
|
||||
@@ -376,6 +367,78 @@ export class AgentRegistry {
|
||||
return (await response.json()) as RuntimeInfo;
|
||||
}
|
||||
|
||||
private async fetchRuntimeInfoSingle(
|
||||
headers: Record<string, string>,
|
||||
credentials: RequestCredentials | undefined,
|
||||
): Promise<RuntimeInfo> {
|
||||
if (!headers["Content-Type"]) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
const response = await fetch(this.runtimeUrl!, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ method: "info" }),
|
||||
...(credentials ? { credentials } : {}),
|
||||
});
|
||||
if ("ok" in response && !(response as Response).ok) {
|
||||
throw new Error(
|
||||
`Runtime info request failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as RuntimeInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detect transport by trying REST first, then falling back to single-endpoint.
|
||||
* Updates `_runtimeTransport` to the detected value so subsequent requests use it directly.
|
||||
*/
|
||||
/**
|
||||
* Auto-detect transport by trying REST first, then falling back to single-endpoint.
|
||||
* Updates `_runtimeTransport` to the detected value so subsequent requests use it directly.
|
||||
*/
|
||||
private async fetchRuntimeInfoAutoDetect(
|
||||
headers: Record<string, string>,
|
||||
credentials: RequestCredentials | undefined,
|
||||
): Promise<RuntimeInfo> {
|
||||
// Try REST first (GET /info)
|
||||
try {
|
||||
const response = await fetch(`${this.runtimeUrl}/info`, {
|
||||
headers: { ...headers },
|
||||
...(credentials ? { credentials } : {}),
|
||||
});
|
||||
// If the response indicates a clear failure (404/405), the endpoint
|
||||
// doesn't exist — fall through to the single-endpoint probe.
|
||||
const status = "status" in response ? (response as Response).status : 200;
|
||||
if (status === 404 || status === 405) {
|
||||
// Not a REST runtime — try single-endpoint below
|
||||
} else {
|
||||
this._runtimeTransport = "rest";
|
||||
return (await response.json()) as RuntimeInfo;
|
||||
}
|
||||
} catch {
|
||||
// REST failed (network error, etc.) — fall through to single-endpoint attempt
|
||||
}
|
||||
|
||||
// Try single-endpoint (POST with { method: "info" })
|
||||
const singleHeaders = { ...headers };
|
||||
if (!singleHeaders["Content-Type"]) {
|
||||
singleHeaders["Content-Type"] = "application/json";
|
||||
}
|
||||
const response = await fetch(this.runtimeUrl!, {
|
||||
method: "POST",
|
||||
headers: singleHeaders,
|
||||
body: JSON.stringify({ method: "info" }),
|
||||
...(credentials ? { credentials } : {}),
|
||||
});
|
||||
if ("ok" in response && !(response as Response).ok) {
|
||||
throw new Error(
|
||||
`Runtime info request failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
this._runtimeTransport = "single";
|
||||
return (await response.json()) as RuntimeInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign agent IDs to a record of agents
|
||||
*/
|
||||
|
||||
@@ -230,7 +230,7 @@ export class CopilotKitCore {
|
||||
|
||||
constructor({
|
||||
runtimeUrl,
|
||||
runtimeTransport = "rest",
|
||||
runtimeTransport = "auto",
|
||||
headers = {},
|
||||
credentials,
|
||||
properties = {},
|
||||
|
||||
@@ -15,7 +15,7 @@ export enum ToolCallStatus {
|
||||
Complete = "complete",
|
||||
}
|
||||
|
||||
export type CopilotRuntimeTransport = "rest" | "single";
|
||||
export type CopilotRuntimeTransport = "rest" | "single" | "auto";
|
||||
export type { RuntimeMode, IntelligenceRuntimeInfo, RuntimeLicenseStatus };
|
||||
|
||||
/**
|
||||
|
||||
@@ -172,7 +172,7 @@ export const CopilotKitProvider: React.FC<CopilotKitProviderProps> = ({
|
||||
frontendTools,
|
||||
humanInTheLoop,
|
||||
showDevConsole = false,
|
||||
useSingleEndpoint = false,
|
||||
useSingleEndpoint,
|
||||
onError,
|
||||
a2ui,
|
||||
}) => {
|
||||
@@ -389,7 +389,12 @@ export const CopilotKitProvider: React.FC<CopilotKitProviderProps> = ({
|
||||
if (copilotkitRef.current === null) {
|
||||
copilotkitRef.current = new CopilotKitCoreReact({
|
||||
runtimeUrl: chatApiEndpoint,
|
||||
runtimeTransport: useSingleEndpoint ? "single" : "rest",
|
||||
runtimeTransport:
|
||||
useSingleEndpoint === true
|
||||
? "single"
|
||||
: useSingleEndpoint === false
|
||||
? "rest"
|
||||
: "auto",
|
||||
headers: mergedHeaders,
|
||||
credentials,
|
||||
properties,
|
||||
@@ -490,7 +495,13 @@ export const CopilotKitProvider: React.FC<CopilotKitProviderProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
copilotkit.setRuntimeUrl(chatApiEndpoint);
|
||||
copilotkit.setRuntimeTransport(useSingleEndpoint ? "single" : "rest");
|
||||
copilotkit.setRuntimeTransport(
|
||||
useSingleEndpoint === true
|
||||
? "single"
|
||||
: useSingleEndpoint === false
|
||||
? "rest"
|
||||
: "auto",
|
||||
);
|
||||
copilotkit.setHeaders(mergedHeaders);
|
||||
copilotkit.setCredentials(credentials);
|
||||
copilotkit.setProperties(properties);
|
||||
|
||||
Reference in New Issue
Block a user