@leoustc/service-llm 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.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # @leoustc/service-llm
2
+
3
+ Expose an OpenAI Codex subscription through OpenAI-compatible HTTP APIs and MCP.
4
+
5
+ The service never executes model tool calls. It can return tool-call output to a client, which remains responsible for deciding whether to execute it.
6
+
7
+ ## Requirements
8
+
9
+ - Node.js 22.19 or newer
10
+ - A ChatGPT Plus or Pro account with Codex access
11
+
12
+ ## Install and log in
13
+
14
+ ```sh
15
+ npm install --global @leoustc/service-llm
16
+ service-llm login
17
+ ```
18
+
19
+ For a headless machine, use the device-code flow:
20
+
21
+ ```sh
22
+ service-llm login --device-code
23
+ ```
24
+
25
+ OAuth credentials are stored at `~/.service-llm/auth.json` with owner-only permissions.
26
+
27
+ If an API request arrives before login, the service starts a device-code login and returns HTTP 200 with `status: "ok"`, a friendly instruction, `device_code`, and `verification_uri`. Complete authentication in the browser, then retry the request.
28
+
29
+ ## Start the service
30
+
31
+ ```sh
32
+ service-llm --port 7060 --api-key secret-one,secret-two
33
+ ```
34
+
35
+ The default bind address is `127.0.0.1`. Use `--host 0.0.0.0` to listen on all interfaces. If no API keys are supplied, endpoints are unauthenticated.
36
+
37
+ Clients can authenticate with either `Authorization: Bearer <key>` or `X-API-Key: <key>`.
38
+
39
+ From a source checkout, build and run with request/response debug logging enabled:
40
+
41
+ ```sh
42
+ make run
43
+ make run PORT=8080 HOST=0.0.0.0 API_KEYS=secret-one,secret-two
44
+ ```
45
+
46
+ Debug output includes request bodies, JSON responses, and individual SSE events. Authentication headers are redacted. Because prompts and model output are logged, do not enable debug logging around sensitive content.
47
+
48
+ Install the current checkout to `~/.bin` without root permissions:
49
+
50
+ ```sh
51
+ make install
52
+ service-llm --help
53
+ ```
54
+
55
+ If necessary, add it to your shell path with `export PATH="$HOME/.bin:$PATH"`. Run `make uninstall` to remove the symlink.
56
+
57
+ ## Endpoints
58
+
59
+ - `GET /health`
60
+ - `POST /v1/chat/completions`, including `stream: true` SSE responses
61
+ - `POST /v1/responses`, including typed SSE responses
62
+ - `POST /mcp`, providing the MCP `ask` tool. `ask` accepts a prompt plus optional `model`, `tools`, and `tool_choice`, and returns model tool calls without executing them.
63
+
64
+ The default model is `gpt-5.4`. A different Codex model can be selected with the request's `model` field.
65
+
66
+ ## Development
67
+
68
+ ```sh
69
+ make build
70
+ make test
71
+ make pack
72
+ make publish
73
+ make clean
74
+ ```
75
+
76
+ Each test run also saves its complete output under `test/logs/` using a timestamped filename.
77
+ The suite sends the live `Write a short story.` prompt through Chat Completions, Responses, and MCP `ask`. Override the HTTP request model with `make test TEST_MODEL=gpt-5.4-mini`.
package/dist/auth.js ADDED
@@ -0,0 +1,115 @@
1
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import {
5
+ loginOpenAICodex,
6
+ loginOpenAICodexDeviceCode,
7
+ refreshOpenAICodexToken,
8
+ } from "@earendil-works/pi-ai/oauth";
9
+
10
+ export const authPath = join(homedir(), ".service-llm", "auth.json");
11
+ let pendingDeviceLogin;
12
+
13
+ async function saveAuth(data) {
14
+ await mkdir(dirname(authPath), { recursive: true, mode: 0o700 });
15
+ const temporaryPath = `${authPath}.${process.pid}.tmp`;
16
+ await writeFile(temporaryPath, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
17
+ await rename(temporaryPath, authPath);
18
+ await chmod(authPath, 0o600);
19
+ }
20
+
21
+ async function readAuth() {
22
+ try {
23
+ return JSON.parse(await readFile(authPath, "utf8"));
24
+ } catch (error) {
25
+ if (error?.code === "ENOENT") return {};
26
+ throw new Error(`Unable to read ${authPath}: ${error.message}`);
27
+ }
28
+ }
29
+
30
+ export async function login({ deviceCode = false } = {}) {
31
+ const credentials = deviceCode
32
+ ? await loginOpenAICodexDeviceCode({
33
+ onDeviceCode(info) {
34
+ console.error(`Open ${info.verificationUri} and enter code: ${info.userCode}`);
35
+ },
36
+ })
37
+ : await loginOpenAICodex({
38
+ onAuth(info) {
39
+ console.error(`Open this URL to log in:\n${info.url}`);
40
+ },
41
+ onPrompt: async () => {
42
+ throw new Error("OAuth callback timed out; retry with service-llm login --device-code");
43
+ },
44
+ });
45
+
46
+ const data = await readAuth();
47
+ data["openai-codex"] = { type: "oauth", ...credentials };
48
+ await saveAuth(data);
49
+ console.error(`Authentication saved to ${authPath}`);
50
+ }
51
+
52
+ export class DeviceLoginRequiredError extends Error {
53
+ constructor(info) {
54
+ super(`Authentication required. Please open ${info.verificationUri} and enter device code ${info.userCode}. After login completes, retry your request.`);
55
+ this.name = "DeviceLoginRequiredError";
56
+ this.deviceCode = info.userCode;
57
+ this.verificationUri = info.verificationUri;
58
+ this.expiresInSeconds = info.expiresInSeconds;
59
+ }
60
+ }
61
+
62
+ async function beginDeviceLogin() {
63
+ if (pendingDeviceLogin) return pendingDeviceLogin.info;
64
+
65
+ let resolveInfo;
66
+ let rejectInfo;
67
+ const infoPromise = new Promise((resolve, reject) => {
68
+ resolveInfo = resolve;
69
+ rejectInfo = reject;
70
+ });
71
+ pendingDeviceLogin = { info: infoPromise };
72
+
73
+ loginOpenAICodexDeviceCode({
74
+ onDeviceCode(info) {
75
+ const publicInfo = {
76
+ userCode: info.userCode,
77
+ verificationUri: info.verificationUri,
78
+ expiresInSeconds: info.expiresInSeconds,
79
+ };
80
+ pendingDeviceLogin.info = Promise.resolve(publicInfo);
81
+ resolveInfo(publicInfo);
82
+ console.error(`Authentication required: open ${info.verificationUri} and enter ${info.userCode}`);
83
+ },
84
+ })
85
+ .then(async (credentials) => {
86
+ const data = await readAuth();
87
+ data["openai-codex"] = { type: "oauth", ...credentials };
88
+ await saveAuth(data);
89
+ console.error(`Authentication completed and saved to ${authPath}`);
90
+ })
91
+ .catch((error) => {
92
+ rejectInfo(error);
93
+ console.error(`Device login failed: ${error.message ?? String(error)}`);
94
+ })
95
+ .finally(() => {
96
+ pendingDeviceLogin = undefined;
97
+ });
98
+
99
+ return infoPromise;
100
+ }
101
+
102
+ export async function getAccessToken() {
103
+ const data = await readAuth();
104
+ let credentials = data["openai-codex"];
105
+ if (!credentials?.access || !credentials?.refresh || !credentials?.expires) {
106
+ throw new DeviceLoginRequiredError(await beginDeviceLogin());
107
+ }
108
+
109
+ if (Date.now() >= credentials.expires - 60_000) {
110
+ credentials = { type: "oauth", ...(await refreshOpenAICodexToken(credentials.refresh)) };
111
+ data["openai-codex"] = credentials;
112
+ await saveAuth(data);
113
+ }
114
+ return credentials.access;
115
+ }
@@ -0,0 +1,79 @@
1
+ import { getModel, stream } from "@earendil-works/pi-ai/compat";
2
+ import { getAccessToken } from "./auth.js";
3
+
4
+ export const defaultModel = "gpt-5.4";
5
+
6
+ function textFromContent(content) {
7
+ if (typeof content === "string") return content;
8
+ if (!Array.isArray(content)) return "";
9
+ return content
10
+ .filter((part) => part?.type === "text" || part?.type === "input_text")
11
+ .map((part) => part.text ?? "")
12
+ .join("\n");
13
+ }
14
+
15
+ export function messagesFromChat(messages = []) {
16
+ let systemPrompt;
17
+ const result = [];
18
+ for (const message of messages) {
19
+ const text = textFromContent(message.content);
20
+ if (message.role === "system" || message.role === "developer") {
21
+ systemPrompt = systemPrompt ? `${systemPrompt}\n\n${text}` : text;
22
+ } else if (message.role === "user") {
23
+ result.push({ role: "user", content: text, timestamp: Date.now() });
24
+ } else if (message.role === "assistant") {
25
+ result.push({
26
+ role: "assistant",
27
+ content: [{ type: "text", text }],
28
+ api: "openai-codex-responses",
29
+ provider: "openai-codex",
30
+ model: defaultModel,
31
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
32
+ stopReason: "stop",
33
+ timestamp: Date.now(),
34
+ });
35
+ }
36
+ }
37
+ return { systemPrompt, messages: result };
38
+ }
39
+
40
+ export function messagesFromResponses(input) {
41
+ if (typeof input === "string") {
42
+ return { messages: [{ role: "user", content: input, timestamp: Date.now() }] };
43
+ }
44
+ return messagesFromChat(Array.isArray(input) ? input : []);
45
+ }
46
+
47
+ function toolsFromOpenAI(tools) {
48
+ if (!Array.isArray(tools)) return undefined;
49
+ return tools.flatMap((tool) => {
50
+ const fn = tool.type === "function" ? (tool.function ?? tool) : null;
51
+ return fn?.name
52
+ ? [{ name: fn.name, description: fn.description ?? "", parameters: fn.parameters ?? { type: "object", properties: {} } }]
53
+ : [];
54
+ });
55
+ }
56
+
57
+ export async function createCompletionStream({ context, model = defaultModel, tools, toolChoice, signal }) {
58
+ const selectedModel = getModel("openai-codex", model) ?? getModel("openai-codex", defaultModel);
59
+ if (!selectedModel) throw new Error(`Unknown Codex model: ${model}`);
60
+ const apiKey = await getAccessToken();
61
+ return stream(selectedModel, { ...context, tools: toolsFromOpenAI(tools) }, {
62
+ apiKey,
63
+ signal,
64
+ onPayload(payload) {
65
+ if (!toolChoice || !payload || typeof payload !== "object") return payload;
66
+ return { ...payload, tool_choice: toolChoice };
67
+ },
68
+ });
69
+ }
70
+
71
+ export function contentFromMessage(message) {
72
+ return message.content.filter((part) => part.type === "text").map((part) => part.text).join("");
73
+ }
74
+
75
+ export function toolCallsFromMessage(message) {
76
+ return message.content
77
+ .filter((part) => part.type === "toolCall")
78
+ .map((part) => ({ id: part.id, type: "function", function: { name: part.name, arguments: JSON.stringify(part.arguments) } }));
79
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import { login } from "./auth.js";
3
+ import { startServer } from "./http.js";
4
+
5
+ function usage() {
6
+ console.error(`Usage:
7
+ service-llm login [--device-code]
8
+ service-llm [--port 7060] [--host 127.0.0.1] [--api-key key1,key2]`);
9
+ }
10
+
11
+ function valueAfter(args, names, fallback) {
12
+ const index = args.findIndex((arg) => names.includes(arg));
13
+ return index < 0 ? fallback : args[index + 1];
14
+ }
15
+
16
+ const args = process.argv.slice(2);
17
+ if (args.includes("--help") || args.includes("-h")) {
18
+ usage();
19
+ } else if (args[0] === "login") {
20
+ await login({ deviceCode: args.includes("--device-code") });
21
+ } else {
22
+ const port = Number(valueAfter(args, ["--port"], "7060"));
23
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("--port must be between 1 and 65535");
24
+ const apiKeyValue = valueAfter(args, ["--api-key", "-api_key"], process.env.SERVICE_LLM_API_KEYS ?? "");
25
+ startServer({ port, host: valueAfter(args, ["--host"], "127.0.0.1"), apiKeys: apiKeyValue.split(",").map((key) => key.trim()) });
26
+ }
package/dist/http.js ADDED
@@ -0,0 +1,205 @@
1
+ import { createServer } from "node:http";
2
+ import { randomUUID } from "node:crypto";
3
+ import { DeviceLoginRequiredError } from "./auth.js";
4
+ import {
5
+ contentFromMessage,
6
+ createCompletionStream,
7
+ defaultModel,
8
+ messagesFromChat,
9
+ messagesFromResponses,
10
+ toolCallsFromMessage,
11
+ } from "./backend.js";
12
+
13
+ const jsonHeaders = { "content-type": "application/json; charset=utf-8" };
14
+ const debugEnabled = ["1", "true", "yes", "service-llm", "*"].includes((process.env.DEBUG ?? "").toLowerCase());
15
+
16
+ function debug(direction, value) {
17
+ if (!debugEnabled) return;
18
+ const rendered = typeof value === "string" ? value : JSON.stringify(value, null, 2);
19
+ console.error(`[service-llm] ${direction}\n${rendered}`);
20
+ }
21
+
22
+ function sendJson(response, status, body) {
23
+ debug(`response ${status}`, body);
24
+ response.writeHead(status, jsonHeaders);
25
+ response.end(JSON.stringify(body));
26
+ }
27
+
28
+ async function readJson(request) {
29
+ const chunks = [];
30
+ let size = 0;
31
+ for await (const chunk of request) {
32
+ size += chunk.length;
33
+ if (size > 10 * 1024 * 1024) throw Object.assign(new Error("Request body too large"), { status: 413 });
34
+ chunks.push(chunk);
35
+ }
36
+ try {
37
+ return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
38
+ } catch {
39
+ throw Object.assign(new Error("Invalid JSON body"), { status: 400 });
40
+ }
41
+ }
42
+
43
+ function isAuthorized(request, apiKeys) {
44
+ if (apiKeys.size === 0) return true;
45
+ const authorization = request.headers.authorization ?? "";
46
+ const token = authorization.startsWith("Bearer ") ? authorization.slice(7) : request.headers["x-api-key"];
47
+ return typeof token === "string" && apiKeys.has(token);
48
+ }
49
+
50
+ function sse(response, event) {
51
+ debug("response SSE", event);
52
+ response.write(`data: ${JSON.stringify(event)}\n\n`);
53
+ }
54
+
55
+ async function collect(streamResult) {
56
+ let result;
57
+ for await (const event of streamResult) {
58
+ if (event.type === "done") result = event.message;
59
+ if (event.type === "error") throw new Error(event.error.errorMessage ?? "Model request failed");
60
+ }
61
+ if (!result) throw new Error("Model returned no result");
62
+ return result;
63
+ }
64
+
65
+ async function chatCompletions(request, response, body) {
66
+ const id = `chatcmpl-${randomUUID().replaceAll("-", "")}`;
67
+ const model = body.model || defaultModel;
68
+ const streamResult = await createCompletionStream({
69
+ context: messagesFromChat(body.messages), model, tools: body.tools, toolChoice: body.tool_choice,
70
+ });
71
+ if (body.stream) {
72
+ response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
73
+ sse(response, { id, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
74
+ for await (const event of streamResult) {
75
+ if (event.type === "text_delta") sse(response, { id, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: { content: event.delta }, finish_reason: null }] });
76
+ if (event.type === "toolcall_end") sse(response, { id, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: { tool_calls: [{ index: event.contentIndex, id: event.toolCall.id, type: "function", function: { name: event.toolCall.name, arguments: JSON.stringify(event.toolCall.arguments) } }] }, finish_reason: null }] });
77
+ if (event.type === "done") sse(response, { id, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: {}, finish_reason: event.reason === "toolUse" ? "tool_calls" : event.reason }] });
78
+ if (event.type === "error") throw new Error(event.error.errorMessage ?? "Model request failed");
79
+ }
80
+ debug("response SSE", "[DONE]");
81
+ response.end("data: [DONE]\n\n");
82
+ return;
83
+ }
84
+ const message = await collect(streamResult);
85
+ const toolCalls = toolCallsFromMessage(message);
86
+ sendJson(response, 200, { id, object: "chat.completion", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, message: { role: "assistant", content: contentFromMessage(message), ...(toolCalls.length ? { tool_calls: toolCalls } : {}) }, finish_reason: message.stopReason === "toolUse" ? "tool_calls" : message.stopReason }], usage: { prompt_tokens: message.usage.input, completion_tokens: message.usage.output, total_tokens: message.usage.totalTokens } });
87
+ }
88
+
89
+ function responseObject(id, model, message, status = "completed") {
90
+ const output = [];
91
+ const text = contentFromMessage(message);
92
+ if (text) output.push({ id: `msg_${randomUUID().replaceAll("-", "")}`, type: "message", status: "completed", role: "assistant", content: [{ type: "output_text", text, annotations: [] }] });
93
+ for (const call of toolCallsFromMessage(message)) output.push({ type: "function_call", id: call.id, call_id: call.id, name: call.function.name, arguments: call.function.arguments, status: "completed" });
94
+ return { id, object: "response", created_at: Math.floor(Date.now() / 1000), status, model, output, usage: { input_tokens: message.usage.input, output_tokens: message.usage.output, total_tokens: message.usage.totalTokens } };
95
+ }
96
+
97
+ async function responses(request, response, body) {
98
+ const id = `resp_${randomUUID().replaceAll("-", "")}`;
99
+ const model = body.model || defaultModel;
100
+ const streamResult = await createCompletionStream({ context: messagesFromResponses(body.input), model, tools: body.tools, toolChoice: body.tool_choice });
101
+ if (body.stream) {
102
+ response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
103
+ const created = { id, object: "response", created_at: Math.floor(Date.now() / 1000), status: "in_progress", model, output: [] };
104
+ sse(response, { type: "response.created", response: created });
105
+ let sequenceNumber = 0;
106
+ let outputIndex = 0;
107
+ for await (const event of streamResult) {
108
+ if (event.type === "text_delta") sse(response, { type: "response.output_text.delta", sequence_number: sequenceNumber++, output_index: 0, content_index: 0, delta: event.delta });
109
+ if (event.type === "toolcall_end") sse(response, { type: "response.output_item.done", sequence_number: sequenceNumber++, output_index: outputIndex++, item: { type: "function_call", id: event.toolCall.id, call_id: event.toolCall.id, name: event.toolCall.name, arguments: JSON.stringify(event.toolCall.arguments), status: "completed" } });
110
+ if (event.type === "done") sse(response, { type: "response.completed", sequence_number: sequenceNumber++, response: responseObject(id, model, event.message) });
111
+ if (event.type === "error") throw new Error(event.error.errorMessage ?? "Model request failed");
112
+ }
113
+ debug("response SSE", "<stream closed>");
114
+ response.end();
115
+ return;
116
+ }
117
+ sendJson(response, 200, responseObject(id, model, await collect(streamResult)));
118
+ }
119
+
120
+ async function ask({ prompt, model = defaultModel, tools, toolChoice }) {
121
+ const streamResult = await createCompletionStream({
122
+ context: messagesFromResponses(prompt),
123
+ model,
124
+ tools,
125
+ toolChoice,
126
+ });
127
+ const message = await collect(streamResult);
128
+ return { text: contentFromMessage(message), toolCalls: toolCallsFromMessage(message), model };
129
+ }
130
+
131
+ async function mcp(response, body) {
132
+ const base = { jsonrpc: "2.0", id: body.id };
133
+ if (body.method === "initialize") return sendJson(response, 200, { ...base, result: { protocolVersion: body.params?.protocolVersion ?? "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "service-llm", version: "0.1.0" } } });
134
+ if (body.method === "notifications/initialized") return response.writeHead(202).end();
135
+ if (body.method === "ping") return sendJson(response, 200, { ...base, result: {} });
136
+ if (body.method === "tools/list") return sendJson(response, 200, { ...base, result: { tools: [{ name: "ask", description: "Ask a Codex model a question and optionally allow it to return function tool calls. Tool calls are never executed.", inputSchema: { type: "object", properties: { prompt: { type: "string" }, model: { type: "string" }, tools: { type: "array", items: { type: "object" } }, tool_choice: {} }, required: ["prompt"], additionalProperties: false } }] } });
137
+ if (body.method === "tools/call" && body.params?.name === "ask") {
138
+ if (typeof body.params.arguments?.prompt !== "string") return sendJson(response, 200, { ...base, error: { code: -32602, message: "ask requires a string prompt" } });
139
+ const answer = await ask({
140
+ prompt: body.params.arguments.prompt,
141
+ model: body.params.arguments.model,
142
+ tools: body.params.arguments.tools,
143
+ toolChoice: body.params.arguments.tool_choice,
144
+ });
145
+ const structuredContent = {
146
+ model: answer.model,
147
+ text: answer.text,
148
+ tool_calls: answer.toolCalls,
149
+ };
150
+ return sendJson(response, 200, {
151
+ ...base,
152
+ result: {
153
+ content: [{ type: "text", text: answer.toolCalls.length ? JSON.stringify(structuredContent) : answer.text }],
154
+ structuredContent,
155
+ },
156
+ });
157
+ }
158
+ return sendJson(response, 200, { ...base, error: { code: -32601, message: "Method not found" } });
159
+ }
160
+
161
+ export function startServer({ port = 7060, host = "127.0.0.1", apiKeys = [] } = {}) {
162
+ const allowedKeys = new Set(apiKeys.filter(Boolean));
163
+ const server = createServer(async (request, response) => {
164
+ try {
165
+ const url = new URL(request.url, `http://${request.headers.host ?? "localhost"}`);
166
+ debug("request", {
167
+ method: request.method,
168
+ path: url.pathname,
169
+ headers: {
170
+ accept: request.headers.accept,
171
+ "content-type": request.headers["content-type"],
172
+ authorization: request.headers.authorization ? "<redacted>" : undefined,
173
+ "x-api-key": request.headers["x-api-key"] ? "<redacted>" : undefined,
174
+ },
175
+ });
176
+ if (request.method === "GET" && url.pathname === "/health") return sendJson(response, 200, { status: "ok" });
177
+ if (!isAuthorized(request, allowedKeys)) return sendJson(response, 401, { error: { message: "Invalid API key", type: "authentication_error" } });
178
+ if (request.method !== "POST") return sendJson(response, 405, { error: { message: "Method not allowed" } });
179
+ const body = await readJson(request);
180
+ debug("request body", body);
181
+ if (url.pathname === "/v1/chat/completions") return await chatCompletions(request, response, body);
182
+ if (url.pathname === "/v1/responses") return await responses(request, response, body);
183
+ if (url.pathname === "/mcp") return await mcp(response, body);
184
+ return sendJson(response, 404, { error: { message: "Not found" } });
185
+ } catch (error) {
186
+ if (response.headersSent) return response.destroy(error);
187
+ if (error instanceof DeviceLoginRequiredError) {
188
+ return sendJson(response, 200, {
189
+ status: "ok",
190
+ authentication_required: true,
191
+ message: error.message,
192
+ device_code: error.deviceCode,
193
+ verification_uri: error.verificationUri,
194
+ expires_in: error.expiresInSeconds,
195
+ });
196
+ }
197
+ sendJson(response, error.status ?? 500, { error: { message: error.message ?? String(error), type: "service_error" } });
198
+ }
199
+ });
200
+ server.listen(port, host, () => {
201
+ console.error(`service-llm listening on http://${host}:${port}`);
202
+ if (debugEnabled) console.error("[service-llm] DEBUG logging enabled");
203
+ });
204
+ return server;
205
+ }
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { authPath, getAccessToken, login } from "./auth.js";
2
+ export { defaultModel } from "./backend.js";
3
+ export { startServer } from "./http.js";
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@leoustc/service-llm",
3
+ "version": "0.1.0",
4
+ "description": "Expose a Codex subscription through OpenAI-compatible and MCP endpoints.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "bin": {
8
+ "service-llm": "./dist/cli.js"
9
+ },
10
+ "exports": {
11
+ ".": "./dist/index.js"
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "build": "node ./scripts/build.mjs",
18
+ "clean": "node ./scripts/clean.mjs",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "dependencies": {
22
+ "@earendil-works/pi-ai": "0.80.6"
23
+ },
24
+ "engines": {
25
+ "node": ">=22.19.0"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "license": "MIT"
31
+ }