@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 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
- The default model is `gpt-5.4`. A different Codex model can be selected with the request's `model` field.
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 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);
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
- credentials = { type: "oauth", ...(await refreshOpenAICodexToken(credentials.refresh)) };
111
- data["openai-codex"] = credentials;
112
- await saveAuth(data);
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 { getModel, stream } from "@earendil-works/pi-ai/compat";
2
- import { getAccessToken } from "./auth.js";
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
- export const defaultModel = "gpt-5.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) => part?.type === "text" || part?.type === "input_text")
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
- export function messagesFromChat(messages = []) {
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: "assistant",
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
- 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",
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
- return { messages: [{ role: "user", content: input, timestamp: Date.now() }] };
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 messagesFromChat(Array.isArray(input) ? input : []);
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 = 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}`);
152
+ export async function createCompletionStream({ context, model, tools, toolChoice, signal }) {
153
+ const resolved = await resolveModel(model);
60
154
  const apiKey = await getAccessToken();
61
- return stream(selectedModel, { ...context, tools: toolsFromOpenAI(tools) }, {
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
- return index < 0 ? fallback : args[index + 1];
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 model = body.model || defaultModel;
68
- const streamResult = await createCompletionStream({
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
- 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");
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 model = body.model || defaultModel;
100
- const streamResult = await createCompletionStream({ context: messagesFromResponses(body.input), model, tools: body.tools, toolChoice: body.tool_choice });
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
- 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");
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 streamResult = await createCompletionStream({
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(streamResult);
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: "0.1.0" } } });
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
@@ -1,3 +1,4 @@
1
1
  export { authPath, getAccessToken, login } from "./auth.js";
2
2
  export { defaultModel } from "./backend.js";
3
3
  export { startServer } from "./http.js";
4
+ export { version } from "./version.js";
@@ -0,0 +1,3 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ export const version = readFileSync(new URL("../VERSION", import.meta.url), "utf8").trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leoustc/service-llm",
3
- "version": "0.1.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",