cctra 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +135 -0
- package/bin/cctra +2 -0
- package/bin/cctra-daemon.exe +0 -0
- package/bin/cctra.js +2 -0
- package/examples/plugins/oauth-internal.js +46 -0
- package/examples/plugins/openai-compatible.js +27 -0
- package/package.json +53 -0
- package/src/canonical/types.ts +132 -0
- package/src/commands/add.ts +159 -0
- package/src/commands/daemon.ts +102 -0
- package/src/commands/ls.ts +49 -0
- package/src/commands/model.ts +95 -0
- package/src/commands/plugin.ts +167 -0
- package/src/commands/rename.ts +33 -0
- package/src/commands/rm.ts +37 -0
- package/src/commands/serve.ts +29 -0
- package/src/commands/shared.ts +14 -0
- package/src/commands/show.ts +46 -0
- package/src/commands/tier.ts +91 -0
- package/src/convert/common/content-blocks.ts +29 -0
- package/src/convert/common/extras.ts +38 -0
- package/src/convert/common/reasoning.ts +19 -0
- package/src/convert/common/system-prompt.ts +15 -0
- package/src/convert/common/tool-calls.ts +29 -0
- package/src/convert/common/usage.ts +19 -0
- package/src/convert/inbound/anthropic-to-canonical.ts +106 -0
- package/src/convert/inbound/chat-to-canonical.ts +132 -0
- package/src/convert/inbound/responses-to-canonical.ts +92 -0
- package/src/convert/outbound/canonical-to-anthropic.ts +62 -0
- package/src/convert/outbound/canonical-to-chat.ts +101 -0
- package/src/convert/outbound/canonical-to-responses.ts +105 -0
- package/src/convert/streaming/inbound/anthropic-stream.ts +14 -0
- package/src/convert/streaming/inbound/chat-stream.ts +219 -0
- package/src/convert/streaming/inbound/pick.ts +21 -0
- package/src/convert/streaming/inbound/responses-stream.ts +276 -0
- package/src/convert/streaming/outbound/format-anthropic.ts +19 -0
- package/src/convert/streaming/outbound/format-chat.ts +133 -0
- package/src/convert/streaming/outbound/format-responses.ts +184 -0
- package/src/convert/upstream/canonical-to-anthropic.ts +111 -0
- package/src/convert/upstream/canonical-to-chat.ts +115 -0
- package/src/convert/upstream/canonical-to-responses.ts +123 -0
- package/src/core/config.ts +156 -0
- package/src/core/model-fetch.ts +124 -0
- package/src/core/resolve.ts +73 -0
- package/src/core/routing.ts +31 -0
- package/src/core/source.ts +28 -0
- package/src/daemon/install.ts +47 -0
- package/src/daemon/platform/linux.ts +65 -0
- package/src/daemon/platform/macos.ts +71 -0
- package/src/daemon/platform/windows.ts +70 -0
- package/src/daemon/start.ts +22 -0
- package/src/daemon/status.ts +19 -0
- package/src/daemon/stop.ts +58 -0
- package/src/index.ts +34 -0
- package/src/plugin/contract.ts +51 -0
- package/src/plugin/host.ts +27 -0
- package/src/plugin/loader.ts +55 -0
- package/src/plugin/sandbox.ts +3 -0
- package/src/providers/presets.ts +167 -0
- package/src/server/anthropic-parser.ts +44 -0
- package/src/server/cancelable-fetch.ts +21 -0
- package/src/server/chat-parser.ts +81 -0
- package/src/server/error-status.ts +18 -0
- package/src/server/error.ts +16 -0
- package/src/server/handlers/chat-completions.ts +94 -0
- package/src/server/handlers/messages.ts +89 -0
- package/src/server/handlers/models.ts +35 -0
- package/src/server/handlers/responses.ts +89 -0
- package/src/server/keepalive.ts +63 -0
- package/src/server/responses-parser.ts +62 -0
- package/src/server/serve.ts +79 -0
- package/src/server/sse.ts +61 -0
- package/src/server/upstream.ts +251 -0
- package/src/tier/builtin.ts +9 -0
- package/src/tier/resolve.ts +33 -0
- package/src/tier/store.ts +3 -0
- package/src/types.ts +94 -0
- package/src/ui/format.ts +44 -0
- package/src/ui/prompts.ts +34 -0
- package/src/utils/fuzzy.ts +48 -0
- package/src/utils/logger.ts +32 -0
- package/src/utils/paths.ts +48 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// POST /anthropic/v1/messages 处理器
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import { anthropicToCanonical } from "../../convert/inbound/anthropic-to-canonical";
|
|
5
|
+
import { callUpstream, callUpstreamStream, UpstreamError } from "../upstream";
|
|
6
|
+
import { canonicalToAnthropicResponse } from "../../convert/outbound/canonical-to-anthropic";
|
|
7
|
+
import { anthropicErrorBody } from "../error";
|
|
8
|
+
import { errorResponseToHttpStatus } from "../error-status";
|
|
9
|
+
import { resolveRoute } from "../../core/routing";
|
|
10
|
+
import { wrapWithKeepalive } from "../keepalive";
|
|
11
|
+
import { loadConfigFile } from "../../core/config";
|
|
12
|
+
import { logger } from "../../utils/logger";
|
|
13
|
+
|
|
14
|
+
export async function handleMessages(req: Request): Promise<Response> {
|
|
15
|
+
const config = loadConfigFile();
|
|
16
|
+
let body: unknown;
|
|
17
|
+
try {
|
|
18
|
+
body = await req.json();
|
|
19
|
+
} catch {
|
|
20
|
+
return Response.json(anthropicErrorBody("Invalid JSON body"), { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
const b = body as { model?: string };
|
|
23
|
+
if (!b.model) {
|
|
24
|
+
return Response.json(anthropicErrorBody("Missing 'model' field"), { status: 400 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let route;
|
|
28
|
+
try {
|
|
29
|
+
route = resolveRoute(b.model, config);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return Response.json(anthropicErrorBody((e as Error).message), { status: 400 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const canonical = anthropicToCanonical(body as Parameters<typeof anthropicToCanonical>[0]);
|
|
35
|
+
canonical.model = route.upstreamModelId;
|
|
36
|
+
|
|
37
|
+
if (canonical.stream) {
|
|
38
|
+
try {
|
|
39
|
+
const { upstreamStream, parser, format } = await callUpstreamStream({
|
|
40
|
+
route,
|
|
41
|
+
canonical,
|
|
42
|
+
clientFormat: "anthropic-messages",
|
|
43
|
+
clientSignal: req.signal,
|
|
44
|
+
});
|
|
45
|
+
const cstream = parser(upstreamStream);
|
|
46
|
+
const encoder = new TextEncoder();
|
|
47
|
+
const inner = new ReadableStream<Uint8Array>({
|
|
48
|
+
async start(controller) {
|
|
49
|
+
try {
|
|
50
|
+
for await (const chunk of cstream) {
|
|
51
|
+
for (const s of format(chunk)) controller.enqueue(encoder.encode(s));
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
logger.error(`[messages:stream] error: ${(e as Error).message}`);
|
|
55
|
+
if (e instanceof UpstreamError) {
|
|
56
|
+
// Anthropic 错误 SSE 事件:event: error / data: {"type":"error",...}
|
|
57
|
+
// 注意:发完 error 后**不再发 message_stop**(cc-switch 二元化约束)
|
|
58
|
+
const errEvent = `event: error\ndata: ${JSON.stringify({
|
|
59
|
+
type: "error",
|
|
60
|
+
error: { type: "api_error", message: e.message },
|
|
61
|
+
})}\n\n`;
|
|
62
|
+
controller.enqueue(encoder.encode(errEvent));
|
|
63
|
+
}
|
|
64
|
+
} finally {
|
|
65
|
+
controller.close();
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return new Response(wrapWithKeepalive(inner), {
|
|
70
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
|
|
71
|
+
});
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return Response.json(anthropicErrorBody((e as Error).message), { status: 500 });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const upstreamRes = await callUpstream({
|
|
79
|
+
route, canonical, clientFormat: "anthropic-messages", clientSignal: req.signal,
|
|
80
|
+
});
|
|
81
|
+
const body = canonicalToAnthropicResponse(upstreamRes);
|
|
82
|
+
const httpStatus = errorResponseToHttpStatus(upstreamRes);
|
|
83
|
+
return httpStatus !== undefined
|
|
84
|
+
? Response.json(body, { status: httpStatus })
|
|
85
|
+
: Response.json(body);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return Response.json(anthropicErrorBody((e as Error).message), { status: 500 });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// GET /v1/models 处理器
|
|
3
|
+
// 聚合所有订阅和插件的模型,按 OpenAI 格式输出
|
|
4
|
+
// ============================================================================
|
|
5
|
+
import { loadConfigFile } from "../../core/config";
|
|
6
|
+
|
|
7
|
+
export function handleModels(): Response {
|
|
8
|
+
const config = loadConfigFile();
|
|
9
|
+
const items: Array<{ id: string; object: "model"; created: number; owned_by: string }> = [];
|
|
10
|
+
const now = Math.floor(Date.now() / 1000);
|
|
11
|
+
|
|
12
|
+
for (const [subName, sub] of Object.entries(config.subscriptions)) {
|
|
13
|
+
for (const m of sub.models) {
|
|
14
|
+
items.push({ id: `${subName}/${m.id}`, object: "model", created: now, owned_by: subName });
|
|
15
|
+
if (m.alias) {
|
|
16
|
+
items.push({ id: `${subName}/${m.alias}`, object: "model", created: now, owned_by: subName });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
for (const [pluginName, plugin] of Object.entries(config.plugins)) {
|
|
21
|
+
if (!plugin.enabled) continue;
|
|
22
|
+
for (const m of plugin.models) {
|
|
23
|
+
items.push({ id: `${pluginName}/${m.id}`, object: "model", created: now, owned_by: pluginName });
|
|
24
|
+
if (m.alias) {
|
|
25
|
+
items.push({ id: `${pluginName}/${m.alias}`, object: "model", created: now, owned_by: pluginName });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// 加 4 个 tier
|
|
30
|
+
for (const t of ["cctra", "cctra-pro", "cctra-flash", "cctra-vision"]) {
|
|
31
|
+
items.push({ id: t, object: "model", created: now, owned_by: "cctra" });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return Response.json({ object: "list", data: items });
|
|
35
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// POST /v1/responses 处理器
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import { responsesToCanonical } from "../../convert/inbound/responses-to-canonical";
|
|
5
|
+
import { callUpstream, callUpstreamStream, UpstreamError } from "../upstream";
|
|
6
|
+
import { canonicalToResponsesResponse } from "../../convert/outbound/canonical-to-responses";
|
|
7
|
+
import { responsesErrorBody } from "../error";
|
|
8
|
+
import { errorResponseToHttpStatus } from "../error-status";
|
|
9
|
+
import { resolveRoute } from "../../core/routing";
|
|
10
|
+
import { wrapWithKeepalive } from "../keepalive";
|
|
11
|
+
import { loadConfigFile } from "../../core/config";
|
|
12
|
+
import { logger } from "../../utils/logger";
|
|
13
|
+
|
|
14
|
+
export async function handleResponses(req: Request): Promise<Response> {
|
|
15
|
+
const config = loadConfigFile();
|
|
16
|
+
let body: unknown;
|
|
17
|
+
try {
|
|
18
|
+
body = await req.json();
|
|
19
|
+
} catch {
|
|
20
|
+
return Response.json(responsesErrorBody("Invalid JSON body"), { status: 400 });
|
|
21
|
+
}
|
|
22
|
+
const b = body as { model?: string };
|
|
23
|
+
if (!b.model) {
|
|
24
|
+
return Response.json(responsesErrorBody("Missing 'model' field"), { status: 400 });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let route;
|
|
28
|
+
try {
|
|
29
|
+
route = resolveRoute(b.model, config);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
return Response.json(responsesErrorBody((e as Error).message), { status: 400 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const canonical = responsesToCanonical(body as Parameters<typeof responsesToCanonical>[0]);
|
|
35
|
+
canonical.model = route.upstreamModelId;
|
|
36
|
+
|
|
37
|
+
if (canonical.stream) {
|
|
38
|
+
try {
|
|
39
|
+
const { upstreamStream, parser, format } = await callUpstreamStream({
|
|
40
|
+
route,
|
|
41
|
+
canonical,
|
|
42
|
+
clientFormat: "openai-responses",
|
|
43
|
+
clientSignal: req.signal,
|
|
44
|
+
});
|
|
45
|
+
const cstream = parser(upstreamStream);
|
|
46
|
+
const encoder = new TextEncoder();
|
|
47
|
+
const inner = new ReadableStream<Uint8Array>({
|
|
48
|
+
async start(controller) {
|
|
49
|
+
try {
|
|
50
|
+
for await (const chunk of cstream) {
|
|
51
|
+
for (const s of format(chunk)) controller.enqueue(encoder.encode(s));
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
logger.error(`[responses:stream] error: ${(e as Error).message}`);
|
|
55
|
+
if (e instanceof UpstreamError) {
|
|
56
|
+
// Responses 错误 SSE 事件:event: response.error / data: {...}
|
|
57
|
+
// 注意:发完 error 后**不再发 response.completed**(cc-switch 二元化约束)
|
|
58
|
+
const errEvent = `event: response.error\ndata: ${JSON.stringify({
|
|
59
|
+
code: e.status?.toString() ?? "upstream_error",
|
|
60
|
+
message: e.message,
|
|
61
|
+
})}\n\n`;
|
|
62
|
+
controller.enqueue(encoder.encode(errEvent));
|
|
63
|
+
}
|
|
64
|
+
} finally {
|
|
65
|
+
controller.close();
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return new Response(wrapWithKeepalive(inner), {
|
|
70
|
+
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
|
|
71
|
+
});
|
|
72
|
+
} catch (e) {
|
|
73
|
+
return Response.json(responsesErrorBody((e as Error).message), { status: 500 });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const upstreamRes = await callUpstream({
|
|
79
|
+
route, canonical, clientFormat: "openai-responses", clientSignal: req.signal,
|
|
80
|
+
});
|
|
81
|
+
const body = canonicalToResponsesResponse(upstreamRes);
|
|
82
|
+
const httpStatus = errorResponseToHttpStatus(upstreamRes);
|
|
83
|
+
return httpStatus !== undefined
|
|
84
|
+
? Response.json(body, { status: httpStatus })
|
|
85
|
+
: Response.json(body);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return Response.json(responsesErrorBody((e as Error).message), { status: 500 });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SSE keepalive:在 inner 流空闲时每 intervalMs 插入 ": keepalive\n\n" 注释行
|
|
3
|
+
// 防止中间网络设备(nginx / proxy / CDN)超时断流
|
|
4
|
+
// ============================================================================
|
|
5
|
+
|
|
6
|
+
const DEFAULT_INTERVAL_MS = 15_000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 包装一个 SSE ReadableStream,定期插入 keepalive 注释行
|
|
10
|
+
* 下游 cancel() 会清掉 timer,避免泄漏
|
|
11
|
+
*/
|
|
12
|
+
export function wrapWithKeepalive(
|
|
13
|
+
inner: ReadableStream<Uint8Array>,
|
|
14
|
+
intervalMs: number = DEFAULT_INTERVAL_MS,
|
|
15
|
+
): ReadableStream<Uint8Array> {
|
|
16
|
+
const encoder = new TextEncoder();
|
|
17
|
+
const keepaliveBytes = encoder.encode(": keepalive\n\n");
|
|
18
|
+
|
|
19
|
+
let timer: ReturnType<typeof setInterval> | null = null;
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
|
|
22
|
+
const stopTimer = (): void => {
|
|
23
|
+
if (timer !== null) {
|
|
24
|
+
clearInterval(timer);
|
|
25
|
+
timer = null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return new ReadableStream<Uint8Array>({
|
|
30
|
+
start(controller) {
|
|
31
|
+
timer = setInterval(() => {
|
|
32
|
+
if (cancelled) return;
|
|
33
|
+
try {
|
|
34
|
+
controller.enqueue(keepaliveBytes);
|
|
35
|
+
} catch {
|
|
36
|
+
stopTimer();
|
|
37
|
+
}
|
|
38
|
+
}, intervalMs);
|
|
39
|
+
|
|
40
|
+
// 后台 pump:用 for-await 兼容 Bun + web stream 类型
|
|
41
|
+
void (async () => {
|
|
42
|
+
try {
|
|
43
|
+
for await (const chunk of inner as unknown as AsyncIterable<Uint8Array>) {
|
|
44
|
+
if (cancelled) break;
|
|
45
|
+
controller.enqueue(chunk);
|
|
46
|
+
}
|
|
47
|
+
} catch (e) {
|
|
48
|
+
if (!cancelled) controller.error(e);
|
|
49
|
+
} finally {
|
|
50
|
+
stopTimer();
|
|
51
|
+
try { controller.close(); } catch { /* 已关 */ }
|
|
52
|
+
}
|
|
53
|
+
})();
|
|
54
|
+
},
|
|
55
|
+
cancel(reason) {
|
|
56
|
+
cancelled = true;
|
|
57
|
+
stopTimer();
|
|
58
|
+
// 不主动 cancel inner:for-await 已隐式锁了 reader,再 cancel 会报 "ReadableStream is locked"
|
|
59
|
+
// pump 会因为 inner 自然结束(fetch abort / source close)退出,或 controller.enqueue 失败时退出
|
|
60
|
+
void reason;
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// 上游响应解析:OpenAI Responses → CanonicalResponse
|
|
3
|
+
// 跟 upstream.ts(orchestrator)配套使用
|
|
4
|
+
// ============================================================================
|
|
5
|
+
import type { CanonicalResponse, CanonicalContentBlock, StopReason } from "../canonical/types";
|
|
6
|
+
|
|
7
|
+
interface ResponsesUpstreamResponse {
|
|
8
|
+
id?: string;
|
|
9
|
+
model?: string;
|
|
10
|
+
status?: "completed" | "incomplete" | "failed" | string;
|
|
11
|
+
output?: Array<
|
|
12
|
+
| { type: "message"; role?: "assistant"; content?: Array<{ type: "output_text"; text: string } | { type: "refusal"; refusal: string }> }
|
|
13
|
+
| { type: "function_call"; call_id: string; name: string; arguments: string }
|
|
14
|
+
| { type: "reasoning"; summary?: Array<{ type: "summary_text"; text: string }> }
|
|
15
|
+
>;
|
|
16
|
+
usage?: {
|
|
17
|
+
input_tokens?: number;
|
|
18
|
+
output_tokens?: number;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseResponsesResponse(raw: unknown, model: string): CanonicalResponse {
|
|
23
|
+
const r = raw as ResponsesUpstreamResponse;
|
|
24
|
+
const blocks: CanonicalContentBlock[] = [];
|
|
25
|
+
|
|
26
|
+
for (const item of r.output ?? []) {
|
|
27
|
+
if (item.type === "message") {
|
|
28
|
+
for (const c of item.content ?? []) {
|
|
29
|
+
if (c.type === "output_text") blocks.push({ type: "text", text: c.text });
|
|
30
|
+
else if (c.type === "refusal") blocks.push({ type: "refusal", refusal: c.refusal });
|
|
31
|
+
}
|
|
32
|
+
} else if (item.type === "function_call") {
|
|
33
|
+
let input: unknown = {};
|
|
34
|
+
try { input = JSON.parse(item.arguments); } catch { /* keep empty */ }
|
|
35
|
+
blocks.push({ type: "tool_use", id: item.call_id, name: item.name, input });
|
|
36
|
+
} else if (item.type === "reasoning") {
|
|
37
|
+
const thinking = (item.summary ?? []).map((s) => s.text).join("");
|
|
38
|
+
if (thinking) blocks.push({ type: "thinking", thinking });
|
|
39
|
+
}
|
|
40
|
+
// 跳过其他 type(reasoning_summary_text 单元素、file_citation 等)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
id: r.id ?? `resp-${Date.now()}`,
|
|
45
|
+
model: r.model ?? model,
|
|
46
|
+
content: blocks,
|
|
47
|
+
stopReason: mapStopReason(r.status),
|
|
48
|
+
usage: {
|
|
49
|
+
inputTokens: r.usage?.input_tokens ?? 0,
|
|
50
|
+
outputTokens: r.usage?.output_tokens ?? 0,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function mapStopReason(s: string | undefined): StopReason {
|
|
56
|
+
switch (s) {
|
|
57
|
+
case "completed": return "end_turn";
|
|
58
|
+
case "incomplete": return "max_tokens";
|
|
59
|
+
case "failed": return "error";
|
|
60
|
+
default: return "end_turn";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// HTTP 服务器入口:Bun.serve() + 路由表
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import { handleChatCompletions } from "./handlers/chat-completions";
|
|
5
|
+
import { handleResponses } from "./handlers/responses";
|
|
6
|
+
import { handleMessages } from "./handlers/messages";
|
|
7
|
+
import { handleModels } from "./handlers/models";
|
|
8
|
+
import { loadConfigFile } from "../core/config";
|
|
9
|
+
import { logger } from "../utils/logger";
|
|
10
|
+
|
|
11
|
+
export function startServer(portOverride?: number): { port: number; stop: () => void } {
|
|
12
|
+
const config = loadConfigFile();
|
|
13
|
+
const port = portOverride ?? config.port;
|
|
14
|
+
|
|
15
|
+
const server = Bun.serve({
|
|
16
|
+
port,
|
|
17
|
+
hostname: "127.0.0.1",
|
|
18
|
+
development: false,
|
|
19
|
+
|
|
20
|
+
async fetch(req: Request): Promise<Response> {
|
|
21
|
+
const url = new URL(req.url);
|
|
22
|
+
|
|
23
|
+
// CORS 预检
|
|
24
|
+
if (req.method === "OPTIONS") {
|
|
25
|
+
return new Response(null, {
|
|
26
|
+
status: 204,
|
|
27
|
+
headers: corsHeaders(),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 健康检查
|
|
32
|
+
if (url.pathname === "/healthz") {
|
|
33
|
+
return Response.json({ ok: true, port });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// OpenAI Chat Completions
|
|
37
|
+
if (url.pathname === "/v1/chat/completions" && req.method === "POST") {
|
|
38
|
+
return handleChatCompletions(req);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// OpenAI Responses
|
|
42
|
+
if (url.pathname === "/v1/responses" && req.method === "POST") {
|
|
43
|
+
return handleResponses(req);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Anthropic Messages(带 /v1 后缀的官方 path)
|
|
47
|
+
if (url.pathname === "/anthropic/v1/messages" && req.method === "POST") {
|
|
48
|
+
return handleMessages(req);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Models listing
|
|
52
|
+
if (url.pathname === "/v1/models" && req.method === "GET") {
|
|
53
|
+
return handleModels();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 未匹配的路径一律 404
|
|
57
|
+
return new Response(`Not Found: ${url.pathname}`, {
|
|
58
|
+
status: 404,
|
|
59
|
+
headers: corsHeaders(),
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const actualPort = server.port ?? port;
|
|
65
|
+
logger.info(`cctra daemon listening on http://127.0.0.1:${actualPort}`);
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
port: actualPort,
|
|
69
|
+
stop: () => server.stop(),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function corsHeaders(): Record<string, string> {
|
|
74
|
+
return {
|
|
75
|
+
"Access-Control-Allow-Origin": "*",
|
|
76
|
+
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
|
77
|
+
"Access-Control-Allow-Headers": "*",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SSE 工具:把 ReadableStream<Uint8Array> 解析成异步事件迭代器
|
|
3
|
+
// 支持跨行 data: 字段([data: ...\n data: ...] → 拼成一个事件)
|
|
4
|
+
// UTF-8 安全
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
export interface SseEvent {
|
|
8
|
+
event?: string;
|
|
9
|
+
data: string;
|
|
10
|
+
id?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function* parseSseStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<SseEvent> {
|
|
14
|
+
const reader = stream.getReader();
|
|
15
|
+
const decoder = new TextDecoder("utf-8");
|
|
16
|
+
let buffer = "";
|
|
17
|
+
let dataLines: string[] = [];
|
|
18
|
+
let eventName: string | undefined;
|
|
19
|
+
|
|
20
|
+
while (true) {
|
|
21
|
+
const { value, done } = await reader.read();
|
|
22
|
+
if (done) break;
|
|
23
|
+
buffer += decoder.decode(value, { stream: true });
|
|
24
|
+
|
|
25
|
+
let idx: number;
|
|
26
|
+
while ((idx = buffer.indexOf("\n")) >= 0) {
|
|
27
|
+
const line = buffer.slice(0, idx).replace(/\r$/, "");
|
|
28
|
+
buffer = buffer.slice(idx + 1);
|
|
29
|
+
|
|
30
|
+
if (line === "") {
|
|
31
|
+
// 空行:分发事件
|
|
32
|
+
if (dataLines.length > 0) {
|
|
33
|
+
yield { event: eventName, data: dataLines.join("\n"), id: undefined };
|
|
34
|
+
dataLines = [];
|
|
35
|
+
eventName = undefined;
|
|
36
|
+
}
|
|
37
|
+
} else if (line.startsWith(":")) {
|
|
38
|
+
// 注释行
|
|
39
|
+
} else if (line.startsWith("data:")) {
|
|
40
|
+
dataLines.push(line.slice(5).trimStart());
|
|
41
|
+
} else if (line.startsWith("event:")) {
|
|
42
|
+
eventName = line.slice(6).trim();
|
|
43
|
+
} else if (line.startsWith("id:")) {
|
|
44
|
+
// 暂不实现
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 收尾:如果 buffer 还有内容
|
|
50
|
+
if (buffer.length > 0) {
|
|
51
|
+
if (buffer.startsWith("data:")) dataLines.push(buffer.slice(5).trimStart());
|
|
52
|
+
if (dataLines.length > 0) {
|
|
53
|
+
yield { event: eventName, data: dataLines.join("\n"), id: undefined };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 把字符串包成 SSE data 行 */
|
|
59
|
+
export function sseDataLine(data: string): string {
|
|
60
|
+
return `data: ${data}\n\n`;
|
|
61
|
+
}
|