fix(ai): restore browser fallback for model network failures
Relay initial browser network and CORS failures through authenticated, workspace-configured model endpoints while keeping native clients direct. Complete failed companion turns so the UI does not keep spinning. Refs #619
This commit is contained in:
1 parent
55f7767fdf
commit
3cc0b2d0ab
13 files changed
+319
-21
No files matched your search
@@ -56,7 +56,7 @@ Related Issue: #<issue-number>
|
||||
|
||||
## 环境、部署与组件约束
|
||||
|
||||
- **AI 模型 API 必须客户端直连**:所有平台、所有功能的 AI 模型 API 请求必须由客户端直接调用模型服务,禁止通过 EdgeEver 后端代理、中转或代为调用。
|
||||
- **AI 模型 API 优先客户端直连**:所有平台、所有功能的 AI 模型 API 请求优先由客户端直接调用模型服务;Web 端遇到浏览器网络或 CORS 限制时,允许通过 EdgeEver 后端兜底。桌面端及原生移动端保持直连;已有 HTTP 响应或开始接收流式结果后,不得因错误自动切换到后端重复调用。
|
||||
- **Cloudflare 部署**:严格按 `docs/agent-deploy-cloudflare.md` 执行。
|
||||
- **跨运行时架构**:项目未来将正式支持 Docker 自托管;实现新功能时必须保持业务逻辑与 Cloudflare 解耦,并为其他运行时预留扩展边界。Cloudflare 与 Docker 必须共用同一套业务代码,仅允许保留薄且稳定、不包含业务判断的运行入口和基础设施驱动适配器。
|
||||
- **数据库 Migration**:数据库或种子变化时,在 `migrations/` 下新增递增编号 SQL,禁止修改已执行的旧 Migration。
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { Hono } from "hono";
|
||||
import { isConfiguredAiEndpoint, registerAiProviderRelay } from "./ai-provider-relay.ts";
|
||||
const provider = { provider: "openai-compatible", base_url: "https://models.example/v1" };
|
||||
const target = provider.base_url + "/chat/completions";
|
||||
const environment = { storage: { db: { prepare: () => ({ bind: (a,b) => {
|
||||
expect(a).toBe("workspace-1"); expect(b).toBe("workspace-1");
|
||||
return { all: async () => ({ results: [provider] }) };
|
||||
} }) } } };
|
||||
const request = (url = target, method = "POST", signal) => new Request("https://notes.example/api/v1/ai/provider-relay", {
|
||||
method: "POST", headers: { "x-edgeever-ai-target": url, "x-edgeever-ai-method": method, "x-edgeever-ai-headers": JSON.stringify({ authorization: "Bearer model-key", "content-type": "application/json", cookie: "session=private", "x-admin": "must-not-forward" }) },
|
||||
body: JSON.stringify({ model: "test", messages: [] }), signal,
|
||||
});
|
||||
const makeApp = (fetchProvider, kind = "user") => {
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { if (kind !== "anonymous") c.set("auth", { kind, actorType: "user", actorId: "user-1", workspaceId: "workspace-1", role: "member", scopes: [] }); await next(); });
|
||||
registerAiProviderRelay(app, fetchProvider); return app;
|
||||
};
|
||||
|
||||
test("authenticated model relay streams unchanged and strips cookies and unrelated headers", async () => {
|
||||
let calls=0;let upstreamSignal;
|
||||
const app = makeApp(async (url, init) => {
|
||||
calls++; upstreamSignal=init.signal;
|
||||
expect(String(url)).toBe(target);expect(init.redirect).toBe("manual");
|
||||
expect(init.headers.get("authorization")).toBe("Bearer model-key");
|
||||
expect(init.headers.has("cookie")).toBe(false); expect(init.headers.has("x-admin")).toBe(false);
|
||||
expect(JSON.parse(await new Response(init.body).text()).model).toBe("test");
|
||||
return new Response('data: test\n\n', { headers: { "content-type": "text/event-stream", "set-cookie": "secret=x" } });
|
||||
});
|
||||
const stop = new AbortController();
|
||||
const result = await app.fetch(request(target,"POST",stop.signal), environment);
|
||||
expect(result.status).toBe(200);expect(await result.text()).toBe('data: test\n\n');
|
||||
expect(result.headers.has("set-cookie")).toBe(false); expect(result.headers.get("cache-control")).toBe("no-store");expect(calls).toBe(1);
|
||||
stop.abort();expect(upstreamSignal.aborted).toBe(true);
|
||||
});
|
||||
for (const [url,method] of [["https://other.example/v1/chat/completions","POST"],["https://models.example/admin","POST"],["https://models.example/v1/chat/completions?url=https://evil.example","POST"],[target,"DELETE"],["https://user:password@models.example/v1/chat/completions","POST"]]) test(`denies unconfigured or non-model relay targets: ${url} ${method}`,async()=>{
|
||||
const app=makeApp(async()=>{throw Error("Must not fetch");});const result=await app.fetch(request(url,method),environment);expect(result.status).toBe(403);
|
||||
});
|
||||
test("provider HTTP errors pass through without becoming an instance login failure",async()=>{
|
||||
const app=makeApp(async()=>Response.json({error:{message:"Bad key"}},{status:401}));
|
||||
expect((await app.fetch(request(),environment)).status).toBe(401);
|
||||
});
|
||||
test("anonymous clients cannot use the relay",async()=>{
|
||||
const app=makeApp(async()=>{throw Error("Must not fetch");},"anonymous");expect((await app.fetch(request(),environment)).status).toBe(401);
|
||||
});
|
||||
test("target matching supports configured Anthropic, Gemini and transcription endpoints",()=>{
|
||||
expect(isConfiguredAiEndpoint(new URL('https://anthropic.example/v1/messages'),'POST',{provider:'anthropic',base_url:'https://anthropic.example/v1'})).toBe(true);
|
||||
expect(isConfiguredAiEndpoint(new URL('https://google.example/v1beta/models/gemini-test:streamGenerateContent?alt=sse&key=test'),'POST',{provider:'google',base_url:'https://google.example/v1beta'})).toBe(true);
|
||||
expect(isConfiguredAiEndpoint(new URL('https://models.example/v1/audio/transcriptions'),'POST',provider)).toBe(true);
|
||||
expect(isConfiguredAiEndpoint(new URL('https://models.example/v1/models'),'GET',provider)).toBe(true);
|
||||
});
|
||||
|
||||
test("relay never follows an upstream redirect to an unconfigured destination",async()=>{
|
||||
let calls=0;const app=makeApp(async(_url,init)=>{calls++;expect(init.redirect).toBe("manual");return new Response(null,{status:307,headers:{location:"https://unconfigured.example/steal"}});});
|
||||
const result=await app.fetch(request(),environment);expect(result.status).toBe(502);expect(calls).toBe(1);expect(result.headers.has("location")).toBe(false);
|
||||
});
|
||||
|
||||
test("Gemini model names cannot escape the configured model endpoint",()=>{
|
||||
expect(isConfiguredAiEndpoint(new URL('https://google.example/v1beta/models/%2f..%2fadmin:generateContent'),'POST',{provider:'google',base_url:'https://google.example/v1beta'})).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Hono } from "hono";
|
||||
import { bodyLimit } from "hono/body-limit";
|
||||
import type { AppEnv } from "./api-context";
|
||||
import { apiError } from "./http-errors";
|
||||
import { getWorkspaceId, requireScopes } from "./request-auth";
|
||||
|
||||
type Provider = { provider: string; base_url: string };
|
||||
const relayHeaders = new Set([
|
||||
"authorization", "x-api-key", "x-goog-api-key", "content-type", "accept",
|
||||
"anthropic-version", "anthropic-beta", "anthropic-dangerous-direct-browser-access",
|
||||
"http-referer", "x-title",
|
||||
]);
|
||||
|
||||
export const isConfiguredAiEndpoint = (target: URL, method: string, config: Provider): boolean => {
|
||||
if (!["http:", "https:"].includes(target.protocol) || target.username || target.password || target.hash) return false;
|
||||
let base: URL;
|
||||
try { base = new URL(config.base_url); } catch { return false; }
|
||||
if (target.origin !== base.origin) return false;
|
||||
const root = base.pathname.replace(/\/+$/, "");
|
||||
if (config.provider === "google") {
|
||||
if ([...target.searchParams.keys()].some(key => key !== "key" && key !== "alt")) return false;
|
||||
if (method === "GET" && target.pathname === `${root}/models`) return true;
|
||||
const suffix = target.pathname.slice(`${root}/models/`.length);
|
||||
return method === "POST" && target.pathname.startsWith(`${root}/models/`)
|
||||
&& /^[A-Za-z0-9._-]+:(?:streamGenerateContent|generateContent)$/.test(suffix);
|
||||
}
|
||||
if (target.search) return false;
|
||||
if (method === "GET") return target.pathname === `${root}/models`;
|
||||
if (method !== "POST") return false;
|
||||
if (config.provider === "anthropic") return target.pathname === `${root}/messages`;
|
||||
return config.provider === "openai-compatible"
|
||||
&& (target.pathname === `${root}/chat/completions` || target.pathname === `${root}/audio/transcriptions`);
|
||||
};
|
||||
|
||||
export const registerAiProviderRelay = (app: Hono<AppEnv>, fetchProvider: typeof fetch = (...args) => fetch(...args)) => {
|
||||
app.post("/api/v1/ai/provider-relay", bodyLimit({ maxSize: 64 * 1024 * 1024 }), async c => {
|
||||
const denied = requireScopes(c, "ai:generate");
|
||||
if (denied) return denied;
|
||||
let target: URL;
|
||||
let headers: Headers;
|
||||
const method = c.req.header("x-edgeever-ai-method") ?? "POST";
|
||||
try {
|
||||
target = new URL(c.req.header("x-edgeever-ai-target") ?? "");
|
||||
const raw = JSON.parse(c.req.header("x-edgeever-ai-headers") ?? "{}");
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Invalid headers");
|
||||
headers = new Headers();
|
||||
for (const [key, value] of Object.entries(raw)) {
|
||||
if (relayHeaders.has(key.toLowerCase()) && typeof value === "string") headers.set(key, value);
|
||||
}
|
||||
} catch { return apiError(c, "ai_relay_invalid", "Invalid model request.", 400); }
|
||||
const workspaceId = getWorkspaceId(c);
|
||||
const rows = await c.env.storage.db.prepare(
|
||||
`SELECT provider, base_url FROM ai_provider_configs WHERE workspace_id = ? AND is_enabled = 1
|
||||
UNION ALL SELECT 'openai-compatible' AS provider, base_url FROM ai_transcription_providers WHERE workspace_id = ? AND is_enabled = 1`,
|
||||
).bind(workspaceId, workspaceId).all<Provider>();
|
||||
if (!rows.results.some(config => isConfiguredAiEndpoint(target, method, config))) {
|
||||
return apiError(c, "ai_relay_target_denied", "Save and enable this model service before using the browser fallback.", 403);
|
||||
}
|
||||
try {
|
||||
const requestInit: RequestInit & { duplex?: "half" } = {
|
||||
method, headers, redirect: "manual", signal: c.req.raw.signal,
|
||||
...(method === "POST" ? { body: c.req.raw.body, duplex: "half" } : {}),
|
||||
};
|
||||
const upstream = await fetchProvider(target, requestInit);
|
||||
if (upstream.status >= 300 && upstream.status < 400) {
|
||||
await upstream.body?.cancel();
|
||||
return apiError(c, "ai_relay_redirect_denied", "Model service redirects are not supported.", 502);
|
||||
}
|
||||
return new Response(upstream.body, {
|
||||
status: upstream.status,
|
||||
headers: { "Content-Type": upstream.headers.get("content-type") ?? "application/json", "Cache-Control": "no-store" },
|
||||
});
|
||||
} catch {
|
||||
return apiError(c, "ai_relay_unavailable", "The instance could not reach the model service.", 502);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
import { encryptSecret } from "./secret-encryption";
|
||||
import { listTagSummaries } from "./tag-service";
|
||||
import { prepareNoteResourceTranscription } from "./resource-transcription";
|
||||
import { registerAiProviderRelay } from "./ai-provider-relay";
|
||||
|
||||
type AiRouteDependencies = {
|
||||
isDemoMode: (environment: Bindings) => boolean;
|
||||
@@ -215,6 +216,7 @@ const resolveAiGenerateFields = async (context: AppContext, input: {
|
||||
};
|
||||
|
||||
export const registerAiRoutes = (app: Hono<AppEnv>, dependencies: AiRouteDependencies) => {
|
||||
registerAiProviderRelay(app);
|
||||
app.get("/api/v1/ai/settings", async (context) => {
|
||||
const denied = requireUser(context);
|
||||
if (denied) return denied;
|
||||
@@ -934,6 +936,6 @@ export const registerAiRoutes = (app: Hono<AppEnv>, dependencies: AiRouteDepende
|
||||
});
|
||||
for (const path of OLD_AI_GENERATION_PATHS) app.post(path, context => {
|
||||
const denied = requireScopes(context, "ai:generate"); if (denied) return denied;
|
||||
return apiError(context, "ai_client_required", "AI requires a client update. Model requests must be sent directly from the client.", 410);
|
||||
return apiError(context, "ai_client_required", "Update the client to use prepared AI requests.", 410);
|
||||
});
|
||||
};
|
||||
@@ -174,20 +174,18 @@ describe("desktop AI routing", () => {
|
||||
throw new Error(`Unexpected request: ${path}`);
|
||||
};
|
||||
const received = [];
|
||||
const requestFailure = status === "network";
|
||||
const streamOptions = { onEvent: event => received.push(event) };
|
||||
const generation = operation === "new"
|
||||
? api.streamCompanion({ id: turn.id, message: "hi" }, streamOptions)
|
||||
: api.resumeCompanionTurn(turn.id, { answers: [] }, streamOptions);
|
||||
if (requestFailure) await expect(generation).rejects.toThrow(/NetworkError/);
|
||||
else await generation;
|
||||
await generation;
|
||||
expect(paths).toEqual([
|
||||
preparePath,
|
||||
...(requestFailure ? [] : [`/api/v1/companion/turns/${turn.id}/complete`]),
|
||||
`/api/v1/companion/turns/${turn.id}/complete`,
|
||||
]);
|
||||
expect(providerAuth).toEqual(["Bearer provider-key"]);
|
||||
expect(completed).toHaveLength(requestFailure ? 0 : 1);
|
||||
if (!requestFailure) expect(completed[0].status).toBe(status === 200 ? "completed" : "failed");
|
||||
expect(completed).toHaveLength(1);
|
||||
expect(completed[0].status).toBe(status === 200 ? "completed" : "failed");
|
||||
if (status === 200) expect(received).toContainEqual({ type: "text-delta", text: "direct reply" });
|
||||
else if (status === "stream-network") expect(received).toContainEqual({ type: "text-delta", text: "partial reply" });
|
||||
else if (status === 401) expect(received.at(-1)).toMatchObject({ type: "error", code: "ai_credentials_rejected" });
|
||||
|
||||
@@ -323,9 +323,9 @@ const desktopAiDirectEnabled = Boolean(
|
||||
|| (typeof window !== "undefined" && window.edgeeverDesktop?.openAiProviderStream),
|
||||
);
|
||||
|
||||
/** Direct client-to-provider transport. Browser CORS failures are surfaced to the caller. */
|
||||
/** Native clients connect directly; Web can relay initial network/CORS failures. */
|
||||
export const directProviderFetch: typeof fetch = (input, init) =>
|
||||
desktopAiDirectEnabled ? desktopProviderFetch(input, init) : fetch(input, init);
|
||||
client.fetchAiProvider(input, init);
|
||||
|
||||
const client = createEdgeEverClient({
|
||||
baseUrl: getConfiguredDesktopApiBaseUrl,
|
||||
@@ -334,6 +334,7 @@ const client = createEdgeEverClient({
|
||||
shouldAttachToken: (path) => path !== "/api/v1/auth/login",
|
||||
onUnauthorized: handleUnauthorized,
|
||||
directAiGeneration: desktopAiDirectEnabled,
|
||||
allowAiServerFallback: !desktopAiDirectEnabled,
|
||||
providerFetch: desktopAiDirectEnabled ? desktopProviderFetch : undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -110,4 +110,4 @@ Cloudflare Worker entrypoint.
|
||||
|
||||
## AI model requests
|
||||
|
||||
All AI model API requests originate from the client, including note generation, conversations, tag suggestions, discoveries, infographics, connection tests, model listing, and extension video summaries. The backend only prepares credentials/context, executes workspace tools, and saves results. Legacy server-generation endpoints return HTTP 410 and require a client update. Upgrade clients and the Web Clipper alongside the instance. Browser model endpoints must allow CORS; private endpoints must be reachable from the client (for example, over a VPN). The extension asks for permission to access the configured model host. Network or permission failures never fall back to server-side generation.
|
||||
AI model requests run directly on the client by default, including note generation, conversations, tag suggestions, discoveries, infographics, connection tests, model listing, and extension video summaries. Web clients retry initial browser network/CORS failures through the authenticated instance relay. The relay only accepts model API endpoints belonging to enabled services configured in the current workspace, preserves streaming and cancellation, and never forwards browser cookies. HTTP errors and failures after a response has started do not trigger another model call. Desktop and native mobile clients continue to connect directly; private endpoints must be reachable from those devices. Browser fallback requires the instance to reach the model endpoint. Legacy generation endpoints still require a client update; the relay reuses client-side AI orchestration instead of duplicating it on the backend.
|
||||
@@ -88,4 +88,4 @@ bun run start:self-hosted
|
||||
|
||||
## AI 模型请求
|
||||
|
||||
所有 AI 模型 API 请求均由客户端直接发起,包括笔记生成、对话、标签建议、发现、信息图、连接测试、模型列表和扩展视频摘要。后端仅准备凭据与上下文、执行工作区工具和保存结果。旧服务端生成接口返回 HTTP 410,要求升级客户端;升级实例时请同步升级各客户端及 Web Clipper。浏览器使用的模型端点须允许 CORS,内网端点须能从客户端访问(例如通过 VPN)。扩展会请求访问所配置模型域名的权限。网络或权限失败不会回退到服务端生成。
|
||||
AI 模型请求默认由客户端直接发起,包括笔记生成、对话、标签建议、发现、信息图、连接测试、模型列表和扩展视频摘要。Web 端初始请求遇到浏览器网络或 CORS 限制时,通过已认证的实例中转兜底。中转仅允许访问当前工作区已保存并启用的模型服务接口,保留流式输出和取消能力,不转发浏览器 Cookie。已有 HTTP 响应或开始接收结果后的错误不会触发第二次模型调用。桌面端及原生移动端继续直连,内网端点须能从设备访问;Web 端兜底则要求实例能访问模型地址。旧生成接口仍要求升级客户端;中转复用客户端的 AI 编排逻辑,不在后端重复实现。
|
||||
@@ -0,0 +1,62 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createEdgeEverClient } from "./index.ts";
|
||||
|
||||
const target = "https://models.example/v1/chat/completions";
|
||||
const init = { method: "POST", headers: { authorization: "Bearer model-key", "content-type": "application/json" }, body: JSON.stringify({ model: "test", messages: [] }) };
|
||||
const response = () => new Response('data: [DONE]\n\n', { headers: { "content-type": "text/event-stream" } });
|
||||
|
||||
for (const mode of ["direct", "cors", "http", "cancel", "stream"]) test(`browser AI transport: ${mode}`, async () => {
|
||||
const calls = []; let directCalls = 0;
|
||||
const abort = new AbortController();
|
||||
const client = createEdgeEverClient({
|
||||
baseUrl: "https://notes.example", token: "instance-session", allowAiServerFallback: true,
|
||||
providerFetch: async (_url, requestInit) => {
|
||||
directCalls++;
|
||||
expect(requestInit.credentials).toBe("omit");
|
||||
expect(requestInit.redirect).toBe("error");
|
||||
if (mode === "cancel") { abort.abort(); throw new TypeError("Failed to fetch"); }
|
||||
if (mode === "cors") throw new TypeError("Failed to fetch");
|
||||
if (mode === "http") return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (mode === "stream") return new Response(new ReadableStream({ start(c) { c.error(new TypeError("stream failed")); } }));
|
||||
return response();
|
||||
},
|
||||
fetch: async (url, requestInit) => {
|
||||
calls.push(String(url));
|
||||
const headers = new Headers(requestInit.headers);
|
||||
expect(headers.get("authorization")).toBe("Bearer instance-session");
|
||||
expect(headers.get("x-edgeever-ai-target")).toBe(target);
|
||||
expect(headers.get("x-edgeever-ai-method")).toBe("POST");
|
||||
expect(JSON.parse(headers.get("x-edgeever-ai-headers")).authorization).toBe("Bearer model-key");
|
||||
expect(new TextDecoder().decode(requestInit.body)).toBe(init.body);
|
||||
return response();
|
||||
},
|
||||
});
|
||||
const promise = client.fetchAiProvider(target, { ...init, signal: abort.signal });
|
||||
if (mode === "cancel") await expect(promise).rejects.toThrow();
|
||||
else {
|
||||
const result = await promise;
|
||||
if (mode === "stream") await expect(result.text()).rejects.toThrow("stream failed");
|
||||
else if (mode === "http") expect(result.status).toBe(401);
|
||||
else expect(await result.text()).toContain("[DONE]");
|
||||
}
|
||||
expect(directCalls).toBe(1);
|
||||
expect(calls).toEqual(mode === "cors" ? ["https://notes.example/api/v1/ai/provider-relay"] : []);
|
||||
});
|
||||
|
||||
test("native clients never enable the browser fallback by default", async () => {
|
||||
const client = createEdgeEverClient({ fetch: async () => { throw new Error("Must not relay"); }, providerFetch: async () => { throw new TypeError("Failed to fetch"); } });
|
||||
await expect(client.fetchAiProvider(target, init)).rejects.toThrow("Failed to fetch");
|
||||
});
|
||||
|
||||
test("fallback preserves multipart audio and the browser boundary", async () => {
|
||||
const form = new FormData(); form.append("file", new Blob(["audio-test"]), "test.wav"); form.append("model", "whisper-1");
|
||||
const client = createEdgeEverClient({ allowAiServerFallback: true, providerFetch: async () => { throw new TypeError("Failed to fetch"); }, fetch: async (_url, requestInit) => {
|
||||
const headers = new Headers(requestInit.headers);
|
||||
const original = JSON.parse(headers.get("x-edgeever-ai-headers"));
|
||||
const parsed = await new Response(requestInit.body, { headers: { "content-type": original["content-type"] } }).formData();
|
||||
expect(parsed.get("model")).toBe("whisper-1"); expect(await parsed.get("file").text()).toBe("audio-test");
|
||||
return Response.json({ text: "transcribed" });
|
||||
} });
|
||||
const result = await client.fetchAiProvider("https://models.example/v1/audio/transcriptions", { method: "POST", body: form });
|
||||
expect(await result.json()).toEqual({ text: "transcribed" });
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { isAiCorsFailure } from "./ai-direct-stream";
|
||||
|
||||
/** Retry only a failed initial fetch. A readable HTTP error or an interrupted
|
||||
* response stream must never start a second model request. */
|
||||
export const fetchAiWithServerFallback = async (
|
||||
input: RequestInfo | URL,
|
||||
init: RequestInit | undefined,
|
||||
directFetch: typeof fetch,
|
||||
relay: (request: Request) => Promise<Response>,
|
||||
): Promise<Response> => {
|
||||
const directInit: RequestInit = { ...init, credentials: "omit", redirect: "error" };
|
||||
const backupInput = input instanceof Request ? input.clone() : input;
|
||||
const backup = new Request(backupInput as ConstructorParameters<typeof Request>[0], directInit);
|
||||
try {
|
||||
return await directFetch(input, directInit);
|
||||
} catch (error) {
|
||||
if (backup.signal.aborted || !isAiCorsFailure(error)) throw error;
|
||||
return relay(backup);
|
||||
}
|
||||
};
|
||||
@@ -105,11 +105,43 @@ describe("direct companion client", () => {
|
||||
});
|
||||
|
||||
test("browser companion reports network failure without instance fallback", async () => {
|
||||
const paths=[];let calls=0;
|
||||
const client=createEdgeEverClient({tryDirectAiGeneration:true,fetch:async url=>{paths.push(String(url));return Response.json(preparedTurn);},providerFetch:async()=>{calls++;throw new TypeError("Failed to fetch");}});
|
||||
await expect(client.streamCompanion({id:preparedTurn.turn.id,message:"hi"},{onEvent(){}})).rejects.toThrow("Failed to fetch");
|
||||
expect(paths).toEqual(["/api/v1/companion/turns/prepare"]);expect(calls).toBe(1);
|
||||
});
|
||||
const paths = [];
|
||||
const completions = [];
|
||||
const events = [];
|
||||
let calls = 0;
|
||||
const client = createEdgeEverClient({
|
||||
fetch: async (url, init) => {
|
||||
paths.push(String(url));
|
||||
if (String(url).endsWith("/prepare")) return Response.json(preparedTurn);
|
||||
if (String(url).endsWith("/complete")) {
|
||||
const completion = JSON.parse(init.body);
|
||||
completions.push(completion);
|
||||
return Response.json({ turn: { ...preparedTurn.turn, ...completion } });
|
||||
}
|
||||
throw new Error(`Unexpected instance request: ${url}`);
|
||||
},
|
||||
providerFetch: async () => {
|
||||
calls++;
|
||||
throw new TypeError("Failed to fetch");
|
||||
},
|
||||
});
|
||||
await client.streamCompanion(
|
||||
{ id: preparedTurn.turn.id, message: "hi" },
|
||||
{ onEvent: event => events.push(event) },
|
||||
);
|
||||
expect(paths).toEqual([
|
||||
"/api/v1/companion/turns/prepare",
|
||||
`/api/v1/companion/turns/${preparedTurn.turn.id}/complete`,
|
||||
]);
|
||||
expect(calls).toBe(1);
|
||||
expect(completions).toHaveLength(1);
|
||||
expect(completions[0].status).toBe("failed");
|
||||
expect(events.at(-1)).toEqual({
|
||||
type: "error",
|
||||
code: "companion_generation_failed",
|
||||
diagnostics: { reason: "network" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("direct provider rejection includes safe diagnostics and completes the failed turn once", async () => {
|
||||
@@ -133,3 +165,30 @@ test("direct provider rejection includes safe diagnostics and completes the fail
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
} finally { log.mockRestore(); }
|
||||
});
|
||||
|
||||
test("browser fallback continues the same prepared turn and completes the streamed reply", async () => {
|
||||
const paths = []; const events = []; let providerCalls = 0;
|
||||
const client = createEdgeEverClient({
|
||||
allowAiServerFallback: true,
|
||||
fetch: async (url, init) => {
|
||||
const path = String(url); paths.push(path);
|
||||
if (path.endsWith("/prepare")) return Response.json(preparedTurn);
|
||||
if (path.endsWith("/provider-relay")) {
|
||||
expect(new Headers(init.headers).get("x-edgeever-ai-target")).toBe(preparedTurn.baseUrl + "/chat/completions");
|
||||
return new Response('data: {"choices":[{"delta":{"content":"Relayed reply"}}]}\n\ndata: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', { headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
if (path.endsWith("/complete")) {
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.status).toBe("completed"); expect(body.response).toBe("Relayed reply");
|
||||
return Response.json({ turn: { ...preparedTurn.turn, ...body } });
|
||||
}
|
||||
throw Error("Unexpected instance request");
|
||||
},
|
||||
providerFetch: async () => { providerCalls++; throw new TypeError("Failed to fetch"); },
|
||||
});
|
||||
await client.streamCompanion({ id: preparedTurn.turn.id, message: "hi" }, { onEvent: event => events.push(event) });
|
||||
expect(paths).toEqual(["/api/v1/companion/turns/prepare", "/api/v1/ai/provider-relay", `/api/v1/companion/turns/${preparedTurn.turn.id}/complete`]);
|
||||
expect(providerCalls).toBe(1);
|
||||
expect(events).toContainEqual({ type: "text-delta", text: "Relayed reply" });
|
||||
expect(events.at(-1)).toMatchObject({ type: "done", turn: { id: preparedTurn.turn.id, status: "completed" } });
|
||||
});
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
} from "@edgeever/shared";
|
||||
import type { ModelMessage } from "ai";
|
||||
import { companionFailureDiagnostics, sealCompanionProcess } from "@edgeever/shared";
|
||||
import { isAiCorsFailure } from "./ai-direct-stream";
|
||||
import { createClientAiModel } from "./ai-model";
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> | null =>
|
||||
@@ -256,7 +255,6 @@ export const streamDirectCompanion = async (
|
||||
await finish("interrupted");
|
||||
return;
|
||||
}
|
||||
if (isAiCorsFailure(error)) throw error;
|
||||
await finish("failed", undefined, error);
|
||||
}
|
||||
};
|
||||
@@ -85,8 +85,10 @@ export type EdgeEverClientOptions = {
|
||||
fetch?: typeof fetch;
|
||||
/** @deprecated All AI generation now uses client-direct transport. */
|
||||
directAiGeneration?: boolean;
|
||||
/** @deprecated All AI generation now uses client-direct transport, without proxy fallback. */
|
||||
/** @deprecated Use allowAiServerFallback for browser transports. */
|
||||
tryDirectAiGeneration?: boolean;
|
||||
/** Web only: retry initial network/CORS failures through the instance. */
|
||||
allowAiServerFallback?: boolean;
|
||||
/** Used only for the model provider request; defaults to `fetch`. */
|
||||
providerFetch?: typeof fetch;
|
||||
beforeRequest?: (context: EdgeEverClientRequestContext) => void | Promise<void>;
|
||||
@@ -349,7 +351,25 @@ export type { SyncBootstrapResponse, SyncChangesResponse };
|
||||
|
||||
export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
|
||||
const getFetch = () => options.fetch ?? globalThis.fetch;
|
||||
const providerFetch = (): typeof fetch => (url, init) => (options.providerFetch ?? getFetch())(url, { ...init, credentials: "omit", redirect: "error" });
|
||||
const providerFetch = (): typeof fetch => async (url, init) => {
|
||||
const directFetch = options.providerFetch ?? getFetch();
|
||||
if (!options.allowAiServerFallback) return directFetch(url, { ...init, credentials: "omit", redirect: "error" });
|
||||
const { fetchAiWithServerFallback } = await import("./ai-provider-relay");
|
||||
return fetchAiWithServerFallback(url, init, directFetch, async target => {
|
||||
const { response } = await send("/api/v1/ai/provider-relay", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"x-edgeever-ai-target": target.url,
|
||||
"x-edgeever-ai-method": target.method,
|
||||
"x-edgeever-ai-headers": JSON.stringify(Object.fromEntries(target.headers)),
|
||||
},
|
||||
body: target.method === "GET" ? undefined : await target.arrayBuffer(),
|
||||
signal: target.signal,
|
||||
});
|
||||
return response;
|
||||
});
|
||||
};
|
||||
const getBaseUrl = () => normalizeBaseUrl(
|
||||
typeof options.baseUrl === "function" ? options.baseUrl() : options.baseUrl,
|
||||
);
|
||||
@@ -697,6 +717,7 @@ export const createEdgeEverClient = (options: EdgeEverClientOptions = {}) => {
|
||||
const pluginCapabilities = createPluginCapabilities(request, requestPluginPublic);
|
||||
|
||||
return {
|
||||
fetchAiProvider: providerFetch(),
|
||||
getInstanceHealth: () => request<InstanceHealth>("/api/health"),
|
||||
...pluginCapabilities,
|
||||
pluginAi: {
|
||||
|
||||
Reference in new issue
Block a user