claude-glm-alt-installer 2.0.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 +711 -0
- package/adapters/anthropic-gateway.ts +153 -0
- package/adapters/map.ts +83 -0
- package/adapters/providers/anthropic-pass.ts +64 -0
- package/adapters/providers/gemini.ts +89 -0
- package/adapters/providers/openai.ts +90 -0
- package/adapters/providers/openrouter.ts +98 -0
- package/adapters/sse.ts +62 -0
- package/adapters/types.ts +36 -0
- package/bin/ccx +109 -0
- package/bin/ccx.ps1 +137 -0
- package/bin/cli.js +75 -0
- package/bin/preinstall.js +29 -0
- package/install.ps1 +1018 -0
- package/install.sh +1015 -0
- package/package.json +63 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Main Fastify server that routes requests by provider prefix
|
|
2
|
+
import Fastify from "fastify";
|
|
3
|
+
import { parseProviderModel, warnIfTools } from "./map.js";
|
|
4
|
+
import type { AnthropicRequest, ProviderModel } from "./types.js";
|
|
5
|
+
import { chatOpenAI } from "./providers/openai.js";
|
|
6
|
+
import { chatOpenRouter } from "./providers/openrouter.js";
|
|
7
|
+
import { chatGemini } from "./providers/gemini.js";
|
|
8
|
+
import { passThrough } from "./providers/anthropic-pass.js";
|
|
9
|
+
import { config } from "dotenv";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
|
|
13
|
+
// Load .env from ~/.claude-proxy/.env
|
|
14
|
+
const envPath = join(homedir(), ".claude-proxy", ".env");
|
|
15
|
+
config({ path: envPath });
|
|
16
|
+
|
|
17
|
+
const PORT = Number(process.env.CLAUDE_PROXY_PORT || 17870);
|
|
18
|
+
|
|
19
|
+
let active: ProviderModel | null = null;
|
|
20
|
+
|
|
21
|
+
const fastify = Fastify({ logger: false });
|
|
22
|
+
|
|
23
|
+
// Health check endpoint
|
|
24
|
+
fastify.get("/healthz", async () => ({
|
|
25
|
+
ok: true,
|
|
26
|
+
active: active ?? { provider: "glm", model: "auto" }
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// Status endpoint (shows current active provider/model)
|
|
30
|
+
fastify.get("/_status", async () => {
|
|
31
|
+
return active ?? { provider: "glm", model: "glm-4.7" };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Main messages endpoint - routes by model prefix
|
|
35
|
+
fastify.post("/v1/messages", async (req, res) => {
|
|
36
|
+
try {
|
|
37
|
+
const body = req.body as AnthropicRequest;
|
|
38
|
+
const defaults = active ?? undefined;
|
|
39
|
+
const { provider, model } = parseProviderModel(body.model, defaults);
|
|
40
|
+
|
|
41
|
+
// Warn if using tools with providers that may not support them
|
|
42
|
+
warnIfTools(body, provider);
|
|
43
|
+
|
|
44
|
+
active = { provider, model };
|
|
45
|
+
|
|
46
|
+
// Validate API keys BEFORE setting headers
|
|
47
|
+
if (provider === "openai") {
|
|
48
|
+
const key = process.env.OPENAI_API_KEY;
|
|
49
|
+
if (!key) {
|
|
50
|
+
throw apiError(401, "OPENAI_API_KEY not set in ~/.claude-proxy/.env");
|
|
51
|
+
}
|
|
52
|
+
// Set headers only after validation
|
|
53
|
+
res.raw.setHeader("Content-Type", "text/event-stream");
|
|
54
|
+
res.raw.setHeader("Cache-Control", "no-cache, no-transform");
|
|
55
|
+
res.raw.setHeader("Connection", "keep-alive");
|
|
56
|
+
// @ts-ignore
|
|
57
|
+
res.raw.flushHeaders?.();
|
|
58
|
+
return chatOpenAI(res, body, model, key);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (provider === "openrouter") {
|
|
62
|
+
const key = process.env.OPENROUTER_API_KEY;
|
|
63
|
+
if (!key) {
|
|
64
|
+
throw apiError(401, "OPENROUTER_API_KEY not set in ~/.claude-proxy/.env");
|
|
65
|
+
}
|
|
66
|
+
res.raw.setHeader("Content-Type", "text/event-stream");
|
|
67
|
+
res.raw.setHeader("Cache-Control", "no-cache, no-transform");
|
|
68
|
+
res.raw.setHeader("Connection", "keep-alive");
|
|
69
|
+
// @ts-ignore
|
|
70
|
+
res.raw.flushHeaders?.();
|
|
71
|
+
return chatOpenRouter(res, body, model, key);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (provider === "gemini") {
|
|
75
|
+
const key = process.env.GEMINI_API_KEY;
|
|
76
|
+
if (!key) {
|
|
77
|
+
throw apiError(401, "GEMINI_API_KEY not set in ~/.claude-proxy/.env");
|
|
78
|
+
}
|
|
79
|
+
res.raw.setHeader("Content-Type", "text/event-stream");
|
|
80
|
+
res.raw.setHeader("Cache-Control", "no-cache, no-transform");
|
|
81
|
+
res.raw.setHeader("Connection", "keep-alive");
|
|
82
|
+
// @ts-ignore
|
|
83
|
+
res.raw.flushHeaders?.();
|
|
84
|
+
return chatGemini(res, body, model, key);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (provider === "anthropic") {
|
|
88
|
+
const base = process.env.ANTHROPIC_UPSTREAM_URL;
|
|
89
|
+
const key = process.env.ANTHROPIC_API_KEY;
|
|
90
|
+
if (!base || !key) {
|
|
91
|
+
throw apiError(
|
|
92
|
+
500,
|
|
93
|
+
"ANTHROPIC_UPSTREAM_URL and ANTHROPIC_API_KEY not set in ~/.claude-proxy/.env"
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
// Don't set headers here - passThrough will do it after validation
|
|
97
|
+
return passThrough({
|
|
98
|
+
res,
|
|
99
|
+
body,
|
|
100
|
+
model,
|
|
101
|
+
baseUrl: base,
|
|
102
|
+
headers: {
|
|
103
|
+
"Content-Type": "application/json",
|
|
104
|
+
"x-api-key": key,
|
|
105
|
+
"anthropic-version": process.env.ANTHROPIC_VERSION || "2023-06-01"
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Default: glm (Z.AI)
|
|
111
|
+
const glmBase = process.env.GLM_UPSTREAM_URL;
|
|
112
|
+
const glmKey = process.env.ZAI_API_KEY || process.env.GLM_API_KEY;
|
|
113
|
+
if (!glmBase || !glmKey) {
|
|
114
|
+
throw apiError(
|
|
115
|
+
500,
|
|
116
|
+
"GLM_UPSTREAM_URL and ZAI_API_KEY not set in ~/.claude-proxy/.env. Run: ccx --setup"
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
// Don't set headers here - passThrough will do it after validation
|
|
120
|
+
return passThrough({
|
|
121
|
+
res,
|
|
122
|
+
body,
|
|
123
|
+
model,
|
|
124
|
+
baseUrl: glmBase,
|
|
125
|
+
headers: {
|
|
126
|
+
"Content-Type": "application/json",
|
|
127
|
+
Authorization: `Bearer ${glmKey}`,
|
|
128
|
+
"anthropic-version": process.env.ANTHROPIC_VERSION || "2023-06-01"
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
} catch (e: any) {
|
|
132
|
+
const status = e?.statusCode ?? 500;
|
|
133
|
+
return res.code(status).send({ error: e?.message || "proxy error" });
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
function apiError(status: number, message: string) {
|
|
138
|
+
const e = new Error(message);
|
|
139
|
+
// @ts-ignore
|
|
140
|
+
e.statusCode = status;
|
|
141
|
+
return e;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
fastify
|
|
145
|
+
.listen({ port: PORT, host: "127.0.0.1" })
|
|
146
|
+
.then(() => {
|
|
147
|
+
console.log(`[ccx] Proxy listening on http://127.0.0.1:${PORT}`);
|
|
148
|
+
console.log(`[ccx] Configure API keys in: ${envPath}`);
|
|
149
|
+
})
|
|
150
|
+
.catch((err) => {
|
|
151
|
+
console.error("[ccx] Failed to start proxy:", err.message);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
});
|
package/adapters/map.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Provider parsing and message mapping utilities
|
|
2
|
+
import { AnthropicMessage, AnthropicRequest, ProviderKey, ProviderModel } from "./types.js";
|
|
3
|
+
|
|
4
|
+
const PROVIDER_PREFIXES: ProviderKey[] = ["openai", "openrouter", "gemini", "glm", "anthropic"];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Parse provider and model from the model field
|
|
8
|
+
* Supports formats: "provider:model" or "provider/model"
|
|
9
|
+
* Falls back to defaults if no valid prefix found
|
|
10
|
+
*/
|
|
11
|
+
export function parseProviderModel(modelField: string, defaults?: ProviderModel): ProviderModel {
|
|
12
|
+
if (!modelField) {
|
|
13
|
+
if (defaults) return defaults;
|
|
14
|
+
throw new Error("Missing 'model' in request");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const sep = modelField.includes(":") ? ":" : modelField.includes("/") ? "/" : null;
|
|
18
|
+
if (!sep) {
|
|
19
|
+
// no prefix: fall back to defaults or assume glm as legacy
|
|
20
|
+
return defaults ?? { provider: "glm", model: modelField };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const [maybeProv, ...rest] = modelField.split(sep);
|
|
24
|
+
const prov = maybeProv.toLowerCase() as ProviderKey;
|
|
25
|
+
|
|
26
|
+
if (!PROVIDER_PREFIXES.includes(prov)) {
|
|
27
|
+
// unrecognized prefix -> use defaults or treat full string as model
|
|
28
|
+
return defaults ?? { provider: "glm", model: modelField };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return { provider: prov, model: rest.join(sep) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Warn if tools are being used with providers that may not support them
|
|
36
|
+
*/
|
|
37
|
+
export function warnIfTools(req: AnthropicRequest, provider: ProviderKey): void {
|
|
38
|
+
if (req.tools && req.tools.length > 0) {
|
|
39
|
+
// Only GLM and Anthropic support tools natively
|
|
40
|
+
if (provider !== "glm" && provider !== "anthropic") {
|
|
41
|
+
console.warn(`[proxy] Warning: ${provider} may not fully support Anthropic-style tools. Passing through anyway.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Convert Anthropic content to plain text
|
|
48
|
+
*/
|
|
49
|
+
export function toPlainText(content: AnthropicMessage["content"]): string {
|
|
50
|
+
if (typeof content === "string") return content;
|
|
51
|
+
return content
|
|
52
|
+
.map((c) => {
|
|
53
|
+
if (typeof c === "string") return c;
|
|
54
|
+
if (c.type === "text") return c.text;
|
|
55
|
+
if (c.type === "tool_result") {
|
|
56
|
+
// Convert tool results to text representation
|
|
57
|
+
if (typeof c.content === "string") return c.content;
|
|
58
|
+
return JSON.stringify(c.content);
|
|
59
|
+
}
|
|
60
|
+
return "";
|
|
61
|
+
})
|
|
62
|
+
.join("");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Convert Anthropic messages to OpenAI format
|
|
67
|
+
*/
|
|
68
|
+
export function toOpenAIMessages(messages: AnthropicMessage[]) {
|
|
69
|
+
return messages.map((m) => ({
|
|
70
|
+
role: m.role,
|
|
71
|
+
content: toPlainText(m.content)
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Convert Anthropic messages to Gemini format
|
|
77
|
+
*/
|
|
78
|
+
export function toGeminiContents(messages: AnthropicMessage[]) {
|
|
79
|
+
return messages.map((m) => ({
|
|
80
|
+
role: m.role === "assistant" ? "model" : "user",
|
|
81
|
+
parts: [{ text: toPlainText(m.content) }]
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Pass-through adapter for Anthropic-compatible upstreams (Anthropic API and Z.AI GLM)
|
|
2
|
+
import { FastifyReply } from "fastify";
|
|
3
|
+
|
|
4
|
+
type PassArgs = {
|
|
5
|
+
res: FastifyReply;
|
|
6
|
+
body: any;
|
|
7
|
+
model: string;
|
|
8
|
+
baseUrl: string;
|
|
9
|
+
headers: Record<string, string>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pass through requests to Anthropic-compatible APIs
|
|
14
|
+
* This works for both:
|
|
15
|
+
* - Anthropic's official API
|
|
16
|
+
* - Z.AI's GLM API (Anthropic-compatible)
|
|
17
|
+
*/
|
|
18
|
+
export async function passThrough({ res, body, model, baseUrl, headers }: PassArgs) {
|
|
19
|
+
const url = `${stripEndSlash(baseUrl)}/v1/messages`;
|
|
20
|
+
|
|
21
|
+
// Ensure stream is true for Claude Code UX
|
|
22
|
+
body.stream = true;
|
|
23
|
+
|
|
24
|
+
const resp = await fetch(url, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers,
|
|
27
|
+
body: JSON.stringify(body)
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (!resp.ok || !resp.body) {
|
|
31
|
+
const text = await safeText(resp);
|
|
32
|
+
const err = new Error(`Upstream error (${resp.status}): ${text}`);
|
|
33
|
+
// @ts-ignore
|
|
34
|
+
err.statusCode = resp.status || 502;
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Pipe upstream SSE as-is (already in Anthropic format)
|
|
39
|
+
res.raw.setHeader("Content-Type", "text/event-stream");
|
|
40
|
+
res.raw.setHeader("Cache-Control", "no-cache, no-transform");
|
|
41
|
+
res.raw.setHeader("Connection", "keep-alive");
|
|
42
|
+
// @ts-ignore
|
|
43
|
+
res.raw.flushHeaders?.();
|
|
44
|
+
|
|
45
|
+
const reader = resp.body.getReader();
|
|
46
|
+
while (true) {
|
|
47
|
+
const { value, done } = await reader.read();
|
|
48
|
+
if (done) break;
|
|
49
|
+
res.raw.write(value);
|
|
50
|
+
}
|
|
51
|
+
res.raw.end();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function stripEndSlash(s: string) {
|
|
55
|
+
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function safeText(resp: Response) {
|
|
59
|
+
try {
|
|
60
|
+
return await resp.text();
|
|
61
|
+
} catch {
|
|
62
|
+
return "<no-body>";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Gemini adapter using streamGenerateContent (SSE)
|
|
2
|
+
import { FastifyReply } from "fastify";
|
|
3
|
+
import { createParser } from "eventsource-parser";
|
|
4
|
+
import { deltaText, startAnthropicMessage, stopAnthropicMessage } from "../sse.js";
|
|
5
|
+
import { toGeminiContents } from "../map.js";
|
|
6
|
+
import type { AnthropicRequest } from "../types.js";
|
|
7
|
+
|
|
8
|
+
const G_BASE = process.env.GEMINI_BASE_URL || "https://generativelanguage.googleapis.com/v1beta";
|
|
9
|
+
|
|
10
|
+
export async function chatGemini(
|
|
11
|
+
res: FastifyReply,
|
|
12
|
+
body: AnthropicRequest,
|
|
13
|
+
model: string,
|
|
14
|
+
apiKey?: string
|
|
15
|
+
) {
|
|
16
|
+
if (!apiKey) {
|
|
17
|
+
throw withStatus(401, "Missing GEMINI_API_KEY. Set it in ~/.claude-proxy/.env");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const url = `${G_BASE}/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse&key=${apiKey}`;
|
|
21
|
+
|
|
22
|
+
const reqBody: any = {
|
|
23
|
+
contents: toGeminiContents(body.messages),
|
|
24
|
+
generationConfig: {
|
|
25
|
+
temperature: body.temperature ?? 0.7,
|
|
26
|
+
maxOutputTokens: body.max_tokens
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Note: Gemini has different tool format, just warn for now
|
|
31
|
+
if (body.tools && body.tools.length > 0) {
|
|
32
|
+
console.warn("[gemini] Tools not yet adapted to Gemini format, skipping");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const resp = await fetch(url, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: { "Content-Type": "application/json" },
|
|
38
|
+
body: JSON.stringify(reqBody)
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!resp.ok || !resp.body) {
|
|
42
|
+
const text = await safeText(resp);
|
|
43
|
+
throw withStatus(resp.status || 500, `Gemini error: ${text}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
startAnthropicMessage(res, model);
|
|
47
|
+
|
|
48
|
+
const reader = resp.body.getReader();
|
|
49
|
+
const decoder = new TextDecoder();
|
|
50
|
+
const parser = createParser((event) => {
|
|
51
|
+
if (event.type !== "event") return;
|
|
52
|
+
const data = event.data;
|
|
53
|
+
if (!data) return;
|
|
54
|
+
try {
|
|
55
|
+
const json = JSON.parse(data);
|
|
56
|
+
// Gemini response: candidates[0].content.parts[].text
|
|
57
|
+
const text =
|
|
58
|
+
json?.candidates?.[0]?.content?.parts
|
|
59
|
+
?.map((p: any) => p?.text || "")
|
|
60
|
+
.join("") || "";
|
|
61
|
+
if (text) deltaText(res, text);
|
|
62
|
+
} catch {
|
|
63
|
+
// ignore parse errors
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
while (true) {
|
|
68
|
+
const { value, done } = await reader.read();
|
|
69
|
+
if (done) break;
|
|
70
|
+
parser.feed(decoder.decode(value));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
stopAnthropicMessage(res);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function withStatus(status: number, message: string) {
|
|
77
|
+
const e = new Error(message);
|
|
78
|
+
// @ts-ignore
|
|
79
|
+
e.statusCode = status;
|
|
80
|
+
return e;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function safeText(resp: Response) {
|
|
84
|
+
try {
|
|
85
|
+
return await resp.text();
|
|
86
|
+
} catch {
|
|
87
|
+
return "<no-body>";
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// OpenAI adapter using chat.completions with SSE streaming
|
|
2
|
+
import { FastifyReply } from "fastify";
|
|
3
|
+
import { createParser } from "eventsource-parser";
|
|
4
|
+
import { deltaText, startAnthropicMessage, stopAnthropicMessage } from "../sse.js";
|
|
5
|
+
import { toOpenAIMessages } from "../map.js";
|
|
6
|
+
import type { AnthropicRequest } from "../types.js";
|
|
7
|
+
|
|
8
|
+
const OPENAI_BASE = process.env.OPENAI_BASE_URL || "https://api.openai.com/v1";
|
|
9
|
+
|
|
10
|
+
export async function chatOpenAI(
|
|
11
|
+
res: FastifyReply,
|
|
12
|
+
body: AnthropicRequest,
|
|
13
|
+
model: string,
|
|
14
|
+
apiKey?: string
|
|
15
|
+
) {
|
|
16
|
+
if (!apiKey) {
|
|
17
|
+
throw withStatus(401, "Missing OPENAI_API_KEY. Set it in ~/.claude-proxy/.env");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const url = `${OPENAI_BASE}/chat/completions`;
|
|
21
|
+
|
|
22
|
+
const oaiBody: any = {
|
|
23
|
+
model,
|
|
24
|
+
messages: toOpenAIMessages(body.messages),
|
|
25
|
+
stream: true,
|
|
26
|
+
temperature: body.temperature ?? 0.7,
|
|
27
|
+
max_tokens: body.max_tokens
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Pass through tools if provided (note: OpenAI format may differ)
|
|
31
|
+
if (body.tools && body.tools.length > 0) {
|
|
32
|
+
console.warn("[openai] Tools passed through but format may not be compatible");
|
|
33
|
+
oaiBody.tools = body.tools;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const resp = await fetch(url, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
Authorization: `Bearer ${apiKey}`,
|
|
40
|
+
"Content-Type": "application/json"
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify(oaiBody)
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (!resp.ok || !resp.body) {
|
|
46
|
+
const text = await safeText(resp);
|
|
47
|
+
throw withStatus(resp.status || 500, `OpenAI error: ${text}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Emit Anthropic SSE start events
|
|
51
|
+
startAnthropicMessage(res, model);
|
|
52
|
+
|
|
53
|
+
const reader = resp.body.getReader();
|
|
54
|
+
const decoder = new TextDecoder();
|
|
55
|
+
const parser = createParser((event) => {
|
|
56
|
+
if (event.type !== "event") return;
|
|
57
|
+
const data = event.data;
|
|
58
|
+
if (!data || data === "[DONE]") return;
|
|
59
|
+
try {
|
|
60
|
+
const json = JSON.parse(data);
|
|
61
|
+
const chunk = json.choices?.[0]?.delta?.content ?? "";
|
|
62
|
+
if (chunk) deltaText(res, chunk);
|
|
63
|
+
} catch {
|
|
64
|
+
// ignore parse errors on keepalives, etc.
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
while (true) {
|
|
69
|
+
const { value, done } = await reader.read();
|
|
70
|
+
if (done) break;
|
|
71
|
+
parser.feed(decoder.decode(value));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
stopAnthropicMessage(res);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function withStatus(status: number, message: string) {
|
|
78
|
+
const e = new Error(message);
|
|
79
|
+
// @ts-ignore
|
|
80
|
+
e.statusCode = status;
|
|
81
|
+
return e;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function safeText(resp: Response) {
|
|
85
|
+
try {
|
|
86
|
+
return await resp.text();
|
|
87
|
+
} catch {
|
|
88
|
+
return "<no-body>";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// OpenRouter adapter (OpenAI-compatible API)
|
|
2
|
+
import { FastifyReply } from "fastify";
|
|
3
|
+
import { createParser } from "eventsource-parser";
|
|
4
|
+
import { deltaText, startAnthropicMessage, stopAnthropicMessage } from "../sse.js";
|
|
5
|
+
import { toOpenAIMessages } from "../map.js";
|
|
6
|
+
import type { AnthropicRequest } from "../types.js";
|
|
7
|
+
|
|
8
|
+
const OR_BASE = process.env.OPENROUTER_BASE_URL || "https://openrouter.ai/api/v1";
|
|
9
|
+
|
|
10
|
+
export async function chatOpenRouter(
|
|
11
|
+
res: FastifyReply,
|
|
12
|
+
body: AnthropicRequest,
|
|
13
|
+
model: string,
|
|
14
|
+
apiKey?: string
|
|
15
|
+
) {
|
|
16
|
+
if (!apiKey) {
|
|
17
|
+
throw withStatus(401, "Missing OPENROUTER_API_KEY. Set it in ~/.claude-proxy/.env");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const url = `${OR_BASE}/chat/completions`;
|
|
21
|
+
const headers: Record<string, string> = {
|
|
22
|
+
Authorization: `Bearer ${apiKey}`,
|
|
23
|
+
"Content-Type": "application/json"
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Add optional OpenRouter headers
|
|
27
|
+
if (process.env.OPENROUTER_REFERER) {
|
|
28
|
+
headers["HTTP-Referer"] = process.env.OPENROUTER_REFERER;
|
|
29
|
+
}
|
|
30
|
+
if (process.env.OPENROUTER_TITLE) {
|
|
31
|
+
headers["X-Title"] = process.env.OPENROUTER_TITLE;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const reqBody: any = {
|
|
35
|
+
model,
|
|
36
|
+
messages: toOpenAIMessages(body.messages),
|
|
37
|
+
stream: true,
|
|
38
|
+
temperature: body.temperature ?? 0.7,
|
|
39
|
+
max_tokens: body.max_tokens
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Pass through tools if provided
|
|
43
|
+
if (body.tools && body.tools.length > 0) {
|
|
44
|
+
console.warn("[openrouter] Tools passed through but format may not be compatible");
|
|
45
|
+
reqBody.tools = body.tools;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const resp = await fetch(url, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
headers,
|
|
51
|
+
body: JSON.stringify(reqBody)
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!resp.ok || !resp.body) {
|
|
55
|
+
const text = await safeText(resp);
|
|
56
|
+
throw withStatus(resp.status || 500, `OpenRouter error: ${text}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
startAnthropicMessage(res, model);
|
|
60
|
+
|
|
61
|
+
const reader = resp.body.getReader();
|
|
62
|
+
const decoder = new TextDecoder();
|
|
63
|
+
const parser = createParser((event) => {
|
|
64
|
+
if (event.type !== "event") return;
|
|
65
|
+
const data = event.data;
|
|
66
|
+
if (!data || data === "[DONE]") return;
|
|
67
|
+
try {
|
|
68
|
+
const json = JSON.parse(data);
|
|
69
|
+
const chunk = json.choices?.[0]?.delta?.content ?? "";
|
|
70
|
+
if (chunk) deltaText(res, chunk);
|
|
71
|
+
} catch {
|
|
72
|
+
// ignore parse errors
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
while (true) {
|
|
77
|
+
const { value, done } = await reader.read();
|
|
78
|
+
if (done) break;
|
|
79
|
+
parser.feed(decoder.decode(value));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
stopAnthropicMessage(res);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function withStatus(status: number, message: string) {
|
|
86
|
+
const e = new Error(message);
|
|
87
|
+
// @ts-ignore
|
|
88
|
+
e.statusCode = status;
|
|
89
|
+
return e;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function safeText(resp: Response) {
|
|
93
|
+
try {
|
|
94
|
+
return await resp.text();
|
|
95
|
+
} catch {
|
|
96
|
+
return "<no-body>";
|
|
97
|
+
}
|
|
98
|
+
}
|
package/adapters/sse.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Server-Sent Events (SSE) utilities for Anthropic-style streaming
|
|
2
|
+
import type { FastifyReply } from "fastify";
|
|
3
|
+
|
|
4
|
+
export function initSSE(res: FastifyReply) {
|
|
5
|
+
res.raw.setHeader("Content-Type", "text/event-stream");
|
|
6
|
+
res.raw.setHeader("Cache-Control", "no-cache, no-transform");
|
|
7
|
+
res.raw.setHeader("Connection", "keep-alive");
|
|
8
|
+
// @ts-ignore
|
|
9
|
+
res.raw.flushHeaders?.();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sendEvent(res: FastifyReply, event: string, data: unknown) {
|
|
13
|
+
res.raw.write(`event: ${event}\n`);
|
|
14
|
+
res.raw.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function endSSE(res: FastifyReply) {
|
|
18
|
+
res.raw.write("event: done\n");
|
|
19
|
+
res.raw.write("data: {}\n\n");
|
|
20
|
+
res.raw.end();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function startAnthropicMessage(res: FastifyReply, model: string) {
|
|
24
|
+
const id = `msg_${Date.now()}`;
|
|
25
|
+
sendEvent(res, "message_start", {
|
|
26
|
+
type: "message_start",
|
|
27
|
+
message: {
|
|
28
|
+
id,
|
|
29
|
+
type: "message",
|
|
30
|
+
role: "assistant",
|
|
31
|
+
model,
|
|
32
|
+
content: [],
|
|
33
|
+
stop_reason: null,
|
|
34
|
+
stop_sequence: null,
|
|
35
|
+
usage: { input_tokens: 0, output_tokens: 0 }
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
sendEvent(res, "content_block_start", {
|
|
39
|
+
type: "content_block_start",
|
|
40
|
+
index: 0,
|
|
41
|
+
content_block: { type: "text", text: "" }
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function deltaText(res: FastifyReply, text: string) {
|
|
46
|
+
if (!text) return;
|
|
47
|
+
sendEvent(res, "content_block_delta", {
|
|
48
|
+
type: "content_block_delta",
|
|
49
|
+
index: 0,
|
|
50
|
+
delta: { type: "text_delta", text }
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function stopAnthropicMessage(res: FastifyReply) {
|
|
55
|
+
sendEvent(res, "content_block_stop", { type: "content_block_stop", index: 0 });
|
|
56
|
+
sendEvent(res, "message_delta", {
|
|
57
|
+
type: "message_delta",
|
|
58
|
+
delta: { stop_reason: "end_turn", stop_sequence: null },
|
|
59
|
+
usage: { output_tokens: 0 }
|
|
60
|
+
});
|
|
61
|
+
sendEvent(res, "message_stop", { type: "message_stop" });
|
|
62
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// TypeScript type definitions for Anthropic API subset
|
|
2
|
+
// Used across all adapter files
|
|
3
|
+
|
|
4
|
+
export type AnthropicContentBlock =
|
|
5
|
+
| { type: "text"; text: string }
|
|
6
|
+
| { type: "image"; source: { type: "base64"; media_type: string; data: string } }
|
|
7
|
+
| { type: "tool_use"; id: string; name: string; input: unknown }
|
|
8
|
+
| { type: "tool_result"; tool_use_id: string; content: string | unknown[] };
|
|
9
|
+
|
|
10
|
+
export type AnthropicMessage = {
|
|
11
|
+
role: "user" | "assistant";
|
|
12
|
+
content: string | AnthropicContentBlock[];
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type AnthropicTool = {
|
|
16
|
+
name: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
input_schema?: unknown;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type AnthropicRequest = {
|
|
22
|
+
model: string;
|
|
23
|
+
messages: AnthropicMessage[];
|
|
24
|
+
max_tokens?: number;
|
|
25
|
+
temperature?: number;
|
|
26
|
+
stream?: boolean;
|
|
27
|
+
tools?: AnthropicTool[];
|
|
28
|
+
system?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ProviderKey = "openai" | "openrouter" | "gemini" | "glm" | "anthropic";
|
|
32
|
+
|
|
33
|
+
export type ProviderModel = {
|
|
34
|
+
provider: ProviderKey;
|
|
35
|
+
model: string;
|
|
36
|
+
};
|