@leoustc/service-llm 0.1.0 → 0.2.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 +3 -1
- package/VERSION +1 -0
- package/dist/auth.js +58 -8
- package/dist/backend.js +118 -22
- package/dist/cli.js +4 -1
- package/dist/http.js +56 -22
- package/dist/index.js +1 -0
- package/dist/version.js +3 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ If necessary, add it to your shell path with `export PATH="$HOME/.bin:$PATH"`. R
|
|
|
61
61
|
- `POST /v1/responses`, including typed SSE responses
|
|
62
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
63
|
|
|
64
|
-
|
|
64
|
+
At login, the available Codex model catalog is saved to `~/.service-llm/models.json`. The lowest-cost available model is selected by default with low reasoning effort. A valid model can be selected with the request's `model` field; unknown model IDs safely fall back to that default, and responses report the model actually used.
|
|
65
65
|
|
|
66
66
|
## Development
|
|
67
67
|
|
|
@@ -73,5 +73,7 @@ make publish
|
|
|
73
73
|
make clean
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
`make publish` validates `VERSION`, synchronizes it into `package.json` and `package-lock.json`, then builds and publishes that version.
|
|
77
|
+
|
|
76
78
|
Each test run also saves its complete output under `test/logs/` using a timestamped filename.
|
|
77
79
|
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/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.2.0
|
package/dist/auth.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { dirname, join } from "node:path";
|
|
4
5
|
import {
|
|
@@ -6,16 +7,50 @@ import {
|
|
|
6
7
|
loginOpenAICodexDeviceCode,
|
|
7
8
|
refreshOpenAICodexToken,
|
|
8
9
|
} from "@earendil-works/pi-ai/oauth";
|
|
10
|
+
import { getModels } from "@earendil-works/pi-ai/compat";
|
|
9
11
|
|
|
10
12
|
export const authPath = join(homedir(), ".service-llm", "auth.json");
|
|
13
|
+
export const modelsPath = join(homedir(), ".service-llm", "models.json");
|
|
11
14
|
let pendingDeviceLogin;
|
|
15
|
+
let refreshPromise;
|
|
16
|
+
let writeQueue = Promise.resolve();
|
|
17
|
+
|
|
18
|
+
function smallestModel(models) {
|
|
19
|
+
return [...models].sort((left, right) => {
|
|
20
|
+
const leftCost = (left.cost?.input ?? Infinity) + (left.cost?.output ?? Infinity);
|
|
21
|
+
const rightCost = (right.cost?.input ?? Infinity) + (right.cost?.output ?? Infinity);
|
|
22
|
+
return leftCost - rightCost || left.id.localeCompare(right.id);
|
|
23
|
+
})[0];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function saveModelCatalog() {
|
|
27
|
+
const models = getModels("openai-codex");
|
|
28
|
+
const fallback = smallestModel(models);
|
|
29
|
+
if (!fallback) throw new Error("No OpenAI Codex models are available");
|
|
30
|
+
const catalog = {
|
|
31
|
+
updated_at: new Date().toISOString(),
|
|
32
|
+
default_model: fallback.id,
|
|
33
|
+
default_reasoning_effort: "low",
|
|
34
|
+
models: models.map((model) => ({ id: model.id, name: model.name })),
|
|
35
|
+
};
|
|
36
|
+
await writeSecureJson(modelsPath, catalog);
|
|
37
|
+
return catalog;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function writeSecureJson(path, data) {
|
|
41
|
+
const operation = writeQueue.then(async () => {
|
|
42
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
43
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
44
|
+
await writeFile(temporaryPath, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
45
|
+
await rename(temporaryPath, path);
|
|
46
|
+
await chmod(path, 0o600);
|
|
47
|
+
});
|
|
48
|
+
writeQueue = operation.catch(() => {});
|
|
49
|
+
return operation;
|
|
50
|
+
}
|
|
12
51
|
|
|
13
52
|
async function saveAuth(data) {
|
|
14
|
-
await
|
|
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);
|
|
53
|
+
await writeSecureJson(authPath, data);
|
|
19
54
|
}
|
|
20
55
|
|
|
21
56
|
async function readAuth() {
|
|
@@ -46,7 +81,9 @@ export async function login({ deviceCode = false } = {}) {
|
|
|
46
81
|
const data = await readAuth();
|
|
47
82
|
data["openai-codex"] = { type: "oauth", ...credentials };
|
|
48
83
|
await saveAuth(data);
|
|
84
|
+
const catalog = await saveModelCatalog();
|
|
49
85
|
console.error(`Authentication saved to ${authPath}`);
|
|
86
|
+
console.error(`Loaded ${catalog.models.length} models; default is ${catalog.default_model} with low reasoning`);
|
|
50
87
|
}
|
|
51
88
|
|
|
52
89
|
export class DeviceLoginRequiredError extends Error {
|
|
@@ -86,6 +123,7 @@ async function beginDeviceLogin() {
|
|
|
86
123
|
const data = await readAuth();
|
|
87
124
|
data["openai-codex"] = { type: "oauth", ...credentials };
|
|
88
125
|
await saveAuth(data);
|
|
126
|
+
await saveModelCatalog();
|
|
89
127
|
console.error(`Authentication completed and saved to ${authPath}`);
|
|
90
128
|
})
|
|
91
129
|
.catch((error) => {
|
|
@@ -107,9 +145,21 @@ export async function getAccessToken() {
|
|
|
107
145
|
}
|
|
108
146
|
|
|
109
147
|
if (Date.now() >= credentials.expires - 60_000) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
148
|
+
if (!refreshPromise) {
|
|
149
|
+
refreshPromise = (async () => {
|
|
150
|
+
const latest = await readAuth();
|
|
151
|
+
const latestCredentials = latest["openai-codex"];
|
|
152
|
+
if (!latestCredentials?.refresh) throw new Error("Stored OpenAI Codex refresh token is missing; log in again");
|
|
153
|
+
if (latestCredentials?.access && Date.now() < latestCredentials.expires - 60_000) return latestCredentials;
|
|
154
|
+
const refreshed = { type: "oauth", ...(await refreshOpenAICodexToken(latestCredentials.refresh)) };
|
|
155
|
+
latest["openai-codex"] = refreshed;
|
|
156
|
+
await saveAuth(latest);
|
|
157
|
+
return refreshed;
|
|
158
|
+
})().finally(() => {
|
|
159
|
+
refreshPromise = undefined;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
credentials = await refreshPromise;
|
|
113
163
|
}
|
|
114
164
|
return credentials.access;
|
|
115
165
|
}
|
package/dist/backend.js
CHANGED
|
@@ -1,20 +1,75 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { getModel, getModels, stream } from "@earendil-works/pi-ai/compat";
|
|
3
|
+
import { getAccessToken, modelsPath } from "./auth.js";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
const zeroUsage = {
|
|
6
|
+
input: 0,
|
|
7
|
+
output: 0,
|
|
8
|
+
cacheRead: 0,
|
|
9
|
+
cacheWrite: 0,
|
|
10
|
+
totalTokens: 0,
|
|
11
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function smallestBuiltinModel() {
|
|
15
|
+
return [...getModels("openai-codex")].sort((left, right) => {
|
|
16
|
+
const leftCost = (left.cost?.input ?? Infinity) + (left.cost?.output ?? Infinity);
|
|
17
|
+
const rightCost = (right.cost?.input ?? Infinity) + (right.cost?.output ?? Infinity);
|
|
18
|
+
return leftCost - rightCost || left.id.localeCompare(right.id);
|
|
19
|
+
})[0];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const defaultModel = smallestBuiltinModel()?.id ?? "gpt-5.4-mini";
|
|
23
|
+
|
|
24
|
+
async function configuredModels() {
|
|
25
|
+
try {
|
|
26
|
+
const catalog = JSON.parse(await readFile(modelsPath, "utf8"));
|
|
27
|
+
return {
|
|
28
|
+
ids: new Set(catalog.models.map((model) => model.id)),
|
|
29
|
+
defaultModel: catalog.default_model,
|
|
30
|
+
reasoningEffort: catalog.default_reasoning_effort ?? "low",
|
|
31
|
+
};
|
|
32
|
+
} catch (error) {
|
|
33
|
+
if (error?.code !== "ENOENT") console.error(`Unable to read ${modelsPath}: ${error.message}`);
|
|
34
|
+
const models = getModels("openai-codex");
|
|
35
|
+
return { ids: new Set(models.map((model) => model.id)), defaultModel, reasoningEffort: "low" };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function resolveModel(requestedModel) {
|
|
40
|
+
const catalog = await configuredModels();
|
|
41
|
+
const selectedId = requestedModel && catalog.ids.has(requestedModel) ? requestedModel : catalog.defaultModel;
|
|
42
|
+
const model = getModel("openai-codex", selectedId);
|
|
43
|
+
if (!model) throw new Error("No usable OpenAI Codex model is available");
|
|
44
|
+
return { model, requestedModel, fallback: Boolean(requestedModel && requestedModel !== selectedId), reasoningEffort: catalog.reasoningEffort };
|
|
45
|
+
}
|
|
5
46
|
|
|
6
47
|
function textFromContent(content) {
|
|
7
48
|
if (typeof content === "string") return content;
|
|
8
49
|
if (!Array.isArray(content)) return "";
|
|
9
50
|
return content
|
|
10
|
-
.filter((part) =>
|
|
51
|
+
.filter((part) => ["text", "input_text", "output_text"].includes(part?.type))
|
|
11
52
|
.map((part) => part.text ?? "")
|
|
12
53
|
.join("\n");
|
|
13
54
|
}
|
|
14
55
|
|
|
15
|
-
|
|
56
|
+
function assistantMessage(content, model) {
|
|
57
|
+
return {
|
|
58
|
+
role: "assistant",
|
|
59
|
+
content,
|
|
60
|
+
api: "openai-codex-responses",
|
|
61
|
+
provider: "openai-codex",
|
|
62
|
+
model,
|
|
63
|
+
usage: structuredClone(zeroUsage),
|
|
64
|
+
stopReason: content.some((part) => part.type === "toolCall") ? "toolUse" : "stop",
|
|
65
|
+
timestamp: Date.now(),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function messagesFromChat(messages = [], model = defaultModel) {
|
|
16
70
|
let systemPrompt;
|
|
17
71
|
const result = [];
|
|
72
|
+
const toolNames = new Map();
|
|
18
73
|
for (const message of messages) {
|
|
19
74
|
const text = textFromContent(message.content);
|
|
20
75
|
if (message.role === "system" || message.role === "developer") {
|
|
@@ -22,14 +77,27 @@ export function messagesFromChat(messages = []) {
|
|
|
22
77
|
} else if (message.role === "user") {
|
|
23
78
|
result.push({ role: "user", content: text, timestamp: Date.now() });
|
|
24
79
|
} else if (message.role === "assistant") {
|
|
80
|
+
const content = text ? [{ type: "text", text }] : [];
|
|
81
|
+
for (const call of message.tool_calls ?? []) {
|
|
82
|
+
const fn = call.function;
|
|
83
|
+
if (!call.id || !fn?.name) continue;
|
|
84
|
+
let args;
|
|
85
|
+
try {
|
|
86
|
+
args = typeof fn.arguments === "string" ? JSON.parse(fn.arguments) : (fn.arguments ?? {});
|
|
87
|
+
} catch {
|
|
88
|
+
args = { raw: fn.arguments };
|
|
89
|
+
}
|
|
90
|
+
content.push({ type: "toolCall", id: call.id, name: fn.name, arguments: args });
|
|
91
|
+
toolNames.set(call.id, fn.name);
|
|
92
|
+
}
|
|
93
|
+
result.push(assistantMessage(content, model));
|
|
94
|
+
} else if (message.role === "tool") {
|
|
25
95
|
result.push({
|
|
26
|
-
role: "
|
|
96
|
+
role: "toolResult",
|
|
97
|
+
toolCallId: message.tool_call_id,
|
|
98
|
+
toolName: message.name ?? toolNames.get(message.tool_call_id) ?? "tool",
|
|
27
99
|
content: [{ type: "text", text }],
|
|
28
|
-
|
|
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",
|
|
100
|
+
isError: false,
|
|
33
101
|
timestamp: Date.now(),
|
|
34
102
|
});
|
|
35
103
|
}
|
|
@@ -37,35 +105,63 @@ export function messagesFromChat(messages = []) {
|
|
|
37
105
|
return { systemPrompt, messages: result };
|
|
38
106
|
}
|
|
39
107
|
|
|
40
|
-
export function messagesFromResponses(input) {
|
|
41
|
-
if (typeof input === "string") {
|
|
42
|
-
|
|
108
|
+
export function messagesFromResponses(input, model = defaultModel) {
|
|
109
|
+
if (typeof input === "string") return { messages: [{ role: "user", content: input, timestamp: Date.now() }] };
|
|
110
|
+
if (!Array.isArray(input)) return { messages: [] };
|
|
111
|
+
|
|
112
|
+
const result = [];
|
|
113
|
+
const toolNames = new Map();
|
|
114
|
+
let systemPrompt;
|
|
115
|
+
for (const item of input) {
|
|
116
|
+
if (item.type === "function_call") {
|
|
117
|
+
let args;
|
|
118
|
+
try {
|
|
119
|
+
args = typeof item.arguments === "string" ? JSON.parse(item.arguments) : (item.arguments ?? {});
|
|
120
|
+
} catch {
|
|
121
|
+
args = { raw: item.arguments };
|
|
122
|
+
}
|
|
123
|
+
const callId = item.call_id ?? item.id;
|
|
124
|
+
toolNames.set(callId, item.name);
|
|
125
|
+
result.push(assistantMessage([{ type: "toolCall", id: callId, name: item.name, arguments: args }], model));
|
|
126
|
+
} else if (item.type === "function_call_output") {
|
|
127
|
+
result.push({
|
|
128
|
+
role: "toolResult",
|
|
129
|
+
toolCallId: item.call_id,
|
|
130
|
+
toolName: toolNames.get(item.call_id) ?? "tool",
|
|
131
|
+
content: [{ type: "text", text: textFromContent(item.output) || String(item.output ?? "") }],
|
|
132
|
+
isError: false,
|
|
133
|
+
timestamp: Date.now(),
|
|
134
|
+
});
|
|
135
|
+
} else {
|
|
136
|
+
const converted = messagesFromChat([item], model);
|
|
137
|
+
if (converted.systemPrompt) systemPrompt = systemPrompt ? `${systemPrompt}\n\n${converted.systemPrompt}` : converted.systemPrompt;
|
|
138
|
+
result.push(...converted.messages);
|
|
139
|
+
}
|
|
43
140
|
}
|
|
44
|
-
return
|
|
141
|
+
return { systemPrompt, messages: result };
|
|
45
142
|
}
|
|
46
143
|
|
|
47
144
|
function toolsFromOpenAI(tools) {
|
|
48
145
|
if (!Array.isArray(tools)) return undefined;
|
|
49
146
|
return tools.flatMap((tool) => {
|
|
50
147
|
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
|
-
: [];
|
|
148
|
+
return fn?.name ? [{ name: fn.name, description: fn.description ?? "", parameters: fn.parameters ?? { type: "object", properties: {} } }] : [];
|
|
54
149
|
});
|
|
55
150
|
}
|
|
56
151
|
|
|
57
|
-
export async function createCompletionStream({ context, model
|
|
58
|
-
const
|
|
59
|
-
if (!selectedModel) throw new Error(`Unknown Codex model: ${model}`);
|
|
152
|
+
export async function createCompletionStream({ context, model, tools, toolChoice, signal }) {
|
|
153
|
+
const resolved = await resolveModel(model);
|
|
60
154
|
const apiKey = await getAccessToken();
|
|
61
|
-
|
|
155
|
+
const events = stream(resolved.model, { ...context, tools: toolsFromOpenAI(tools) }, {
|
|
62
156
|
apiKey,
|
|
63
157
|
signal,
|
|
158
|
+
reasoningEffort: resolved.reasoningEffort,
|
|
64
159
|
onPayload(payload) {
|
|
65
160
|
if (!toolChoice || !payload || typeof payload !== "object") return payload;
|
|
66
161
|
return { ...payload, tool_choice: toolChoice };
|
|
67
162
|
},
|
|
68
163
|
});
|
|
164
|
+
return { events, model: resolved.model.id, fallback: resolved.fallback, requestedModel: resolved.requestedModel };
|
|
69
165
|
}
|
|
70
166
|
|
|
71
167
|
export function contentFromMessage(message) {
|
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,10 @@ function usage() {
|
|
|
10
10
|
|
|
11
11
|
function valueAfter(args, names, fallback) {
|
|
12
12
|
const index = args.findIndex((arg) => names.includes(arg));
|
|
13
|
-
|
|
13
|
+
if (index < 0) return fallback;
|
|
14
|
+
const value = args[index + 1];
|
|
15
|
+
if (!value || value.startsWith("-")) throw new Error(`${args[index]} requires a value`);
|
|
16
|
+
return value;
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
const args = process.argv.slice(2);
|
package/dist/http.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { DeviceLoginRequiredError } from "./auth.js";
|
|
4
|
+
import { version } from "./version.js";
|
|
4
5
|
import {
|
|
5
6
|
contentFromMessage,
|
|
6
7
|
createCompletionStream,
|
|
@@ -47,9 +48,9 @@ function isAuthorized(request, apiKeys) {
|
|
|
47
48
|
return typeof token === "string" && apiKeys.has(token);
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function sse(response, event) {
|
|
51
|
+
function sse(response, event, named = false) {
|
|
51
52
|
debug("response SSE", event);
|
|
52
|
-
response.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
53
|
+
response.write(`${named && event.type ? `event: ${event.type}\n` : ""}data: ${JSON.stringify(event)}\n\n`);
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
async function collect(streamResult) {
|
|
@@ -64,18 +65,23 @@ async function collect(streamResult) {
|
|
|
64
65
|
|
|
65
66
|
async function chatCompletions(request, response, body) {
|
|
66
67
|
const id = `chatcmpl-${randomUUID().replaceAll("-", "")}`;
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
context: messagesFromChat(body.messages), model, tools: body.tools, toolChoice: body.tool_choice,
|
|
68
|
+
const completion = await createCompletionStream({
|
|
69
|
+
context: messagesFromChat(body.messages, body.model), model: body.model, tools: body.tools, toolChoice: body.tool_choice,
|
|
70
70
|
});
|
|
71
|
+
const { events: streamResult, model } = completion;
|
|
71
72
|
if (body.stream) {
|
|
72
73
|
response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
73
74
|
sse(response, { id, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
75
|
+
let toolIndex = 0;
|
|
76
|
+
try {
|
|
77
|
+
for await (const event of streamResult) {
|
|
78
|
+
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 }] });
|
|
79
|
+
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: toolIndex++, id: event.toolCall.id, type: "function", function: { name: event.toolCall.name, arguments: JSON.stringify(event.toolCall.arguments) } }] }, finish_reason: null }] });
|
|
80
|
+
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 }] });
|
|
81
|
+
if (event.type === "error") throw new Error(event.error.errorMessage ?? "Model request failed");
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
sse(response, { error: { message: error.message ?? String(error), type: "service_error" } });
|
|
79
85
|
}
|
|
80
86
|
debug("response SSE", "[DONE]");
|
|
81
87
|
response.end("data: [DONE]\n\n");
|
|
@@ -96,19 +102,47 @@ function responseObject(id, model, message, status = "completed") {
|
|
|
96
102
|
|
|
97
103
|
async function responses(request, response, body) {
|
|
98
104
|
const id = `resp_${randomUUID().replaceAll("-", "")}`;
|
|
99
|
-
const
|
|
100
|
-
const
|
|
105
|
+
const completion = await createCompletionStream({ context: messagesFromResponses(body.input, body.model), model: body.model, tools: body.tools, toolChoice: body.tool_choice });
|
|
106
|
+
const { events: streamResult, model } = completion;
|
|
101
107
|
if (body.stream) {
|
|
102
108
|
response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
|
|
103
109
|
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 });
|
|
110
|
+
sse(response, { type: "response.created", sequence_number: 0, response: created }, true);
|
|
105
111
|
let sequenceNumber = 0;
|
|
106
112
|
let outputIndex = 0;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
113
|
+
let textStarted = false;
|
|
114
|
+
let text = "";
|
|
115
|
+
let textItem;
|
|
116
|
+
try {
|
|
117
|
+
for await (const event of streamResult) {
|
|
118
|
+
if (event.type === "text_delta") {
|
|
119
|
+
if (!textStarted) {
|
|
120
|
+
textStarted = true;
|
|
121
|
+
textItem = { id: `msg_${randomUUID().replaceAll("-", "")}`, type: "message", status: "in_progress", role: "assistant", content: [] };
|
|
122
|
+
sse(response, { type: "response.output_item.added", sequence_number: ++sequenceNumber, output_index: outputIndex, item: textItem }, true);
|
|
123
|
+
sse(response, { type: "response.content_part.added", sequence_number: ++sequenceNumber, output_index: outputIndex, item_id: textItem.id, content_index: 0, part: { type: "output_text", text: "", annotations: [] } }, true);
|
|
124
|
+
}
|
|
125
|
+
text += event.delta;
|
|
126
|
+
sse(response, { type: "response.output_text.delta", sequence_number: ++sequenceNumber, output_index: outputIndex, item_id: textItem.id, content_index: 0, delta: event.delta }, true);
|
|
127
|
+
}
|
|
128
|
+
if (event.type === "toolcall_end") {
|
|
129
|
+
const 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" };
|
|
130
|
+
sse(response, { type: "response.output_item.added", sequence_number: ++sequenceNumber, output_index, item: { ...item, status: "in_progress", arguments: "" } }, true);
|
|
131
|
+
sse(response, { type: "response.function_call_arguments.done", sequence_number: ++sequenceNumber, output_index, item_id: item.id, arguments: item.arguments }, true);
|
|
132
|
+
sse(response, { type: "response.output_item.done", sequence_number: ++sequenceNumber, output_index: outputIndex++, item }, true);
|
|
133
|
+
}
|
|
134
|
+
if (event.type === "done") {
|
|
135
|
+
if (textStarted) {
|
|
136
|
+
sse(response, { type: "response.output_text.done", sequence_number: ++sequenceNumber, output_index, item_id: textItem.id, content_index: 0, text }, true);
|
|
137
|
+
sse(response, { type: "response.content_part.done", sequence_number: ++sequenceNumber, output_index, item_id: textItem.id, content_index: 0, part: { type: "output_text", text, annotations: [] } }, true);
|
|
138
|
+
sse(response, { type: "response.output_item.done", sequence_number: ++sequenceNumber, output_index: outputIndex++, item: { ...textItem, status: "completed", content: [{ type: "output_text", text, annotations: [] }] } }, true);
|
|
139
|
+
}
|
|
140
|
+
sse(response, { type: "response.completed", sequence_number: ++sequenceNumber, response: responseObject(id, model, event.message) }, true);
|
|
141
|
+
}
|
|
142
|
+
if (event.type === "error") throw new Error(event.error.errorMessage ?? "Model request failed");
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
sse(response, { type: "error", sequence_number: ++sequenceNumber, error: { message: error.message ?? String(error), type: "service_error" } }, true);
|
|
112
146
|
}
|
|
113
147
|
debug("response SSE", "<stream closed>");
|
|
114
148
|
response.end();
|
|
@@ -118,19 +152,19 @@ async function responses(request, response, body) {
|
|
|
118
152
|
}
|
|
119
153
|
|
|
120
154
|
async function ask({ prompt, model = defaultModel, tools, toolChoice }) {
|
|
121
|
-
const
|
|
155
|
+
const completion = await createCompletionStream({
|
|
122
156
|
context: messagesFromResponses(prompt),
|
|
123
157
|
model,
|
|
124
158
|
tools,
|
|
125
159
|
toolChoice,
|
|
126
160
|
});
|
|
127
|
-
const message = await collect(
|
|
128
|
-
return { text: contentFromMessage(message), toolCalls: toolCallsFromMessage(message), model };
|
|
161
|
+
const message = await collect(completion.events);
|
|
162
|
+
return { text: contentFromMessage(message), toolCalls: toolCallsFromMessage(message), model: completion.model };
|
|
129
163
|
}
|
|
130
164
|
|
|
131
165
|
async function mcp(response, body) {
|
|
132
166
|
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
|
|
167
|
+
if (body.method === "initialize") return sendJson(response, 200, { ...base, result: { protocolVersion: body.params?.protocolVersion ?? "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "service-llm", version } } });
|
|
134
168
|
if (body.method === "notifications/initialized") return response.writeHead(202).end();
|
|
135
169
|
if (body.method === "ping") return sendJson(response, 200, { ...base, result: {} });
|
|
136
170
|
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 } }] } });
|
package/dist/index.js
CHANGED
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leoustc/service-llm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Expose a Codex subscription through OpenAI-compatible and MCP endpoints.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
".": "./dist/index.js"
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
|
-
"dist"
|
|
14
|
+
"dist",
|
|
15
|
+
"VERSION"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
17
18
|
"build": "node ./scripts/build.mjs",
|