@heventure/model-provider-x 0.1.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.
@@ -0,0 +1,103 @@
1
+ import { createServer } from "node:http";
2
+ import { anthropicMessageToChatRequest, chatCompletionToAnthropicMessage, createAnthropicStreamEvents, formatSseEvent } from "./anthropic-chat.js";
3
+ import { parseOpenAiSseStream } from "./sse.js";
4
+ export async function startProxyServer(input) {
5
+ const profile = input.config.profiles[input.profileId];
6
+ if (!profile) {
7
+ throw new Error(`Unknown provider profile: ${input.profileId}`);
8
+ }
9
+ const host = input.host ?? input.config.proxy.host;
10
+ const port = input.port ?? input.config.proxy.port;
11
+ const fetchImpl = input.fetchImpl ?? globalThis.fetch;
12
+ const authToken = input.config.proxy.authToken;
13
+ const server = createServer(async (request, response) => {
14
+ try {
15
+ if (request.method === "GET" && request.url === "/health") {
16
+ writeJson(response, 200, { ok: true, profile: profile.id });
17
+ return;
18
+ }
19
+ if (!isAuthorized(request, authToken)) {
20
+ writeJson(response, 401, { error: { type: "authentication_error", message: "Missing or invalid proxy token" } });
21
+ return;
22
+ }
23
+ if (request.method === "GET" && request.url === "/v1/models") {
24
+ writeJson(response, 200, {
25
+ data: profile.models.map((model) => ({ id: model, display_name: model }))
26
+ });
27
+ return;
28
+ }
29
+ if (request.method === "POST" && request.url === "/v1/messages") {
30
+ await handleMessages(request, response, profile, fetchImpl);
31
+ return;
32
+ }
33
+ writeJson(response, 404, { error: { type: "not_found_error", message: "Not found" } });
34
+ }
35
+ catch (error) {
36
+ writeJson(response, statusForError(error), {
37
+ error: { type: "invalid_request_error", message: error instanceof Error ? error.message : String(error) }
38
+ });
39
+ }
40
+ });
41
+ await new Promise((resolve) => server.listen(port, host, resolve));
42
+ const address = server.address();
43
+ return {
44
+ baseURL: `http://${address.address}:${address.port}`,
45
+ close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
46
+ };
47
+ }
48
+ async function handleMessages(request, response, profile, fetchImpl) {
49
+ const body = (await readJson(request));
50
+ const chatRequest = anthropicMessageToChatRequest(body);
51
+ const upstream = await fetchImpl(`${profile.baseURL.replace(/\/+$/, "")}/chat/completions`, {
52
+ method: "POST",
53
+ headers: {
54
+ "content-type": "application/json",
55
+ ...(profile.apiKey ? { Authorization: `Bearer ${profile.apiKey}` } : {})
56
+ },
57
+ body: JSON.stringify(chatRequest)
58
+ });
59
+ if (!upstream.ok) {
60
+ const message = upstream.text ? await upstream.text() : upstream.statusText;
61
+ writeJson(response, upstream.status ?? 502, { error: { type: "upstream_error", message } });
62
+ return;
63
+ }
64
+ if (body.stream) {
65
+ if (!upstream.body) {
66
+ throw new Error("Upstream streaming response did not include a body");
67
+ }
68
+ const chunks = (await parseOpenAiSseStream(upstream.body));
69
+ response.writeHead(200, {
70
+ "content-type": "text/event-stream; charset=utf-8",
71
+ "cache-control": "no-cache",
72
+ connection: "keep-alive"
73
+ });
74
+ for (const event of createAnthropicStreamEvents(chunks, body.model)) {
75
+ response.write(formatSseEvent(event));
76
+ }
77
+ response.end();
78
+ return;
79
+ }
80
+ if (!upstream.json) {
81
+ throw new Error("Upstream response did not include JSON");
82
+ }
83
+ writeJson(response, 200, chatCompletionToAnthropicMessage((await upstream.json())));
84
+ }
85
+ function isAuthorized(request, authToken) {
86
+ const authorization = request.headers.authorization;
87
+ const apiKey = request.headers["x-api-key"];
88
+ return authorization === `Bearer ${authToken}` || apiKey === authToken;
89
+ }
90
+ async function readJson(request) {
91
+ const chunks = [];
92
+ for await (const chunk of request) {
93
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
94
+ }
95
+ return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
96
+ }
97
+ function writeJson(response, status, body) {
98
+ response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
99
+ response.end(JSON.stringify(body));
100
+ }
101
+ function statusForError(error) {
102
+ return error instanceof Error && error.message.startsWith("Unsupported Anthropic content block type") ? 400 : 500;
103
+ }
@@ -0,0 +1,37 @@
1
+ export async function parseOpenAiSseStream(body) {
2
+ const reader = body.getReader();
3
+ const decoder = new TextDecoder();
4
+ let buffer = "";
5
+ const chunks = [];
6
+ while (true) {
7
+ const { done, value } = await reader.read();
8
+ if (done) {
9
+ break;
10
+ }
11
+ buffer += decoder.decode(value, { stream: true });
12
+ const parts = buffer.split(/\n\n/);
13
+ buffer = parts.pop() ?? "";
14
+ for (const part of parts) {
15
+ const parsed = parseSsePart(part);
16
+ if (parsed !== undefined) {
17
+ chunks.push(parsed);
18
+ }
19
+ }
20
+ }
21
+ const parsed = parseSsePart(buffer);
22
+ if (parsed !== undefined) {
23
+ chunks.push(parsed);
24
+ }
25
+ return chunks;
26
+ }
27
+ function parseSsePart(part) {
28
+ const data = part
29
+ .split(/\r?\n/)
30
+ .filter((line) => line.startsWith("data:"))
31
+ .map((line) => line.slice("data:".length).trim())
32
+ .join("\n");
33
+ if (!data || data === "[DONE]") {
34
+ return undefined;
35
+ }
36
+ return JSON.parse(data);
37
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { copyFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ export function getDefaultClaudeSettingsPath(homeDir = homedir()) {
5
+ return join(homeDir, ".claude", "settings.json");
6
+ }
7
+ export function mergeClaudeCodeSettings(settings, proxy) {
8
+ const env = {
9
+ ...(isRecord(settings.env) ? stringifyRecord(settings.env) : {}),
10
+ ANTHROPIC_BASE_URL: proxy.baseURL,
11
+ ANTHROPIC_AUTH_TOKEN: proxy.authToken
12
+ };
13
+ if (proxy.enableModelDiscovery) {
14
+ env.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY = "1";
15
+ }
16
+ if (proxy.defaultModel) {
17
+ env.ANTHROPIC_MODEL = proxy.defaultModel;
18
+ }
19
+ return {
20
+ ...settings,
21
+ env
22
+ };
23
+ }
24
+ export async function writeClaudeCodeSettings(input) {
25
+ const targetPath = input.targetPath ?? getDefaultClaudeSettingsPath();
26
+ await mkdir(dirname(targetPath), { recursive: true });
27
+ const exists = await fileExists(targetPath);
28
+ const current = exists ? JSON.parse(await readFile(targetPath, "utf8")) : {};
29
+ let backupPath;
30
+ if (exists) {
31
+ backupPath = `${targetPath}.${timestamp()}.bak`;
32
+ await copyFile(targetPath, backupPath);
33
+ }
34
+ const next = mergeClaudeCodeSettings(current, input.proxy);
35
+ await writeFile(targetPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
36
+ return { targetPath, backupPath };
37
+ }
38
+ async function fileExists(path) {
39
+ try {
40
+ return (await stat(path)).isFile();
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ function stringifyRecord(record) {
47
+ return Object.fromEntries(Object.entries(record).map(([key, value]) => [key, String(value)]));
48
+ }
49
+ function isRecord(value) {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value);
51
+ }
52
+ function timestamp() {
53
+ return new Date().toISOString().replace(/[:.]/g, "-");
54
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@heventure/model-provider-x",
3
+ "version": "0.1.0",
4
+ "description": "TUI configurator and local API proxy for wiring custom model providers into OpenCode and Claude Code.",
5
+ "private": false,
6
+ "license": "MIT",
7
+ "author": "HHWY Inc.",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/HughesCuit/model-provider-x.git"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/HughesCuit/model-provider-x/issues"
14
+ },
15
+ "homepage": "https://github.com/HughesCuit/model-provider-x#readme",
16
+ "type": "module",
17
+ "main": "dist/cli/index.js",
18
+ "bin": {
19
+ "model-provider-x": "dist/cli/index.js"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.build.json",
31
+ "prepack": "npm run build",
32
+ "start": "node dist/cli/index.js",
33
+ "test": "vitest run",
34
+ "test:watch": "vitest",
35
+ "lint": "eslint . --ext .ts,.tsx",
36
+ "format": "prettier --write ."
37
+ },
38
+ "dependencies": {
39
+ "jsonc-parser": "^3.3.1"
40
+ },
41
+ "devDependencies": {
42
+ "@eslint/js": "^9.0.0",
43
+ "@types/node": "^24.0.0",
44
+ "eslint": "^9.0.0",
45
+ "prettier": "^3.0.0",
46
+ "typescript": "^5.9.0",
47
+ "typescript-eslint": "^8.0.0",
48
+ "vitest": "^3.0.0"
49
+ }
50
+ }