@columenlabs/lumen-ai 0.1.99 → 0.1.101

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.
@@ -155,6 +155,14 @@ function openaiStreamError(code, plan) {
155
155
  return 'OpenAI declined to answer this message.';
156
156
  return `OpenAI could not answer (${code || 'no reason given'}). It is logged; try again or pick another model.`;
157
157
  }
158
+ /** Set once a plan backend refuses `max_output_tokens`, for the rest of the isolate. */
159
+ let planCapRefused = false;
160
+ const refusesCap = (err) => err instanceof OpenAI.APIError &&
161
+ err.status === 400 &&
162
+ /max_output_tokens/.test(`${err.message} ${err.error?.param ?? ''}`);
163
+ /** Whether a response's output has words or a call to show. */
164
+ const shows = (output) => output.some((b) => b.type === 'function_call' ||
165
+ (b.type === 'message' && b.content.some((c) => c.type === 'output_text' && c.text)));
158
166
  const isApiError = (err) => err instanceof Anthropic.APIError || err instanceof OpenAI.APIError;
159
167
  export class Assistant {
160
168
  o;
@@ -1093,7 +1101,9 @@ export class Assistant {
1093
1101
  strict: false,
1094
1102
  }));
1095
1103
  // The chat so far is plain text turns; this answer's calls are appended as OpenAI items.
1096
- const input = o.messages.map((m) => ({
1104
+ // An earlier answer that said nothing (only a cut mark) is left out: an empty turn is refused.
1105
+ const input = o.messages
1106
+ .map((m) => ({
1097
1107
  role: m.role,
1098
1108
  content: typeof m.content === 'string'
1099
1109
  ? m.content
@@ -1101,15 +1111,19 @@ export class Assistant {
1101
1111
  .map((b) => (b.type === 'text' ? b.text : ''))
1102
1112
  .filter(Boolean)
1103
1113
  .join('\n'),
1104
- }));
1114
+ }))
1115
+ .filter((m) => m.role !== 'assistant' || m.content.replace('…(cut)', '').trim() !== '');
1105
1116
  let answer = '';
1106
1117
  let force = o.first?.type === 'tool' ? { type: 'function', name: o.first.name } : undefined;
1107
- // A plan sign-in takes function tools only inside a namespace and no output cap.
1108
- const sent = (only) => {
1118
+ // A plan sign-in takes function tools only inside a namespace. Its output cap is sent too,
1119
+ // unless the backend refused one before (then it keeps its own).
1120
+ const sent = (only, cap) => {
1109
1121
  const fns = only ? fnTools.filter((t) => t.name === only) : fnTools;
1122
+ const out = cap ? { max_output_tokens: session.tier.out ?? DEFAULT_OUT } : {};
1110
1123
  if (!plan)
1111
- return { tools: fns, max_output_tokens: session.tier.out ?? DEFAULT_OUT };
1124
+ return { tools: fns, ...out };
1112
1125
  return {
1126
+ ...out,
1113
1127
  tools: [
1114
1128
  {
1115
1129
  type: 'namespace',
@@ -1132,8 +1146,8 @@ export class Assistant {
1132
1146
  let partial = '';
1133
1147
  // Event types seen and the items finished one by one: a plan sign-in's final event may carry
1134
1148
  // an empty `output`, its items having come only as `response.output_item.done`.
1135
- const seen = {};
1136
- const items = [];
1149
+ let seen = {};
1150
+ let items = [];
1137
1151
  // An error OpenAI sends inside the stream: logged in full, shown as a plain sentence.
1138
1152
  const streamFailure = (er) => {
1139
1153
  if (partial)
@@ -1150,16 +1164,24 @@ export class Assistant {
1150
1164
  }));
1151
1165
  return new AssistantError(openaiStreamError(er.code || er.type || '', plan), 'upstream');
1152
1166
  };
1153
- const res = await (async () => {
1167
+ // One call of this round; asked again (fresh stream state) when the plan refuses the cap or
1168
+ // spends its whole output on reasoning.
1169
+ const ask = (cap, eff) => {
1170
+ partial = '';
1171
+ seen = {};
1172
+ items = [];
1173
+ return askOnce(cap, eff);
1174
+ };
1175
+ const askOnce = (cap, eff) => (async () => {
1154
1176
  const stream = await client.responses.create({
1155
1177
  model: session.model.id,
1156
1178
  instructions,
1157
1179
  input,
1158
- ...sent(only),
1180
+ ...sent(only, cap),
1159
1181
  store: false,
1160
1182
  stream: true,
1161
1183
  include: ['reasoning.encrypted_content'],
1162
- ...(effort ? { reasoning: { effort } } : {}),
1184
+ ...(eff ? { reasoning: { effort: eff } } : {}),
1163
1185
  ...(choice ? { tool_choice: choice } : {}),
1164
1186
  }, signal ? { signal } : {});
1165
1187
  let done;
@@ -1198,6 +1220,38 @@ export class Assistant {
1198
1220
  }
1199
1221
  throw err;
1200
1222
  });
1223
+ let cap = !plan || !planCapRefused;
1224
+ let res;
1225
+ try {
1226
+ res = await ask(cap, effort);
1227
+ }
1228
+ catch (err) {
1229
+ // A plan backend that refuses an output cap keeps its own: ask once more without it.
1230
+ if (!(plan && cap && refusesCap(err)))
1231
+ throw err;
1232
+ planCapRefused = true;
1233
+ cap = false;
1234
+ console.error(JSON.stringify({ event: 'assistant_openai_cap_refused', model: session.model.id }));
1235
+ res = await ask(cap, effort);
1236
+ }
1237
+ // A reasoning model can spend the whole output on thinking and say nothing: ask once more
1238
+ // with less thinking before giving up.
1239
+ if (res.status === 'incomplete' &&
1240
+ res.incomplete_details?.reason === 'max_output_tokens' &&
1241
+ !partial &&
1242
+ !shows(res.output?.length ? res.output : items) &&
1243
+ effort !== 'low') {
1244
+ console.error(JSON.stringify({
1245
+ event: 'assistant_openai_retry',
1246
+ model: session.model.id,
1247
+ plan,
1248
+ cap,
1249
+ why: 'no_words',
1250
+ usage: res.usage ?? null,
1251
+ events: seen,
1252
+ }));
1253
+ res = await ask(cap, 'low');
1254
+ }
1201
1255
  if (res.status === 'failed') {
1202
1256
  if (partial)
1203
1257
  sink.push({ kind: 'text', text: partial });
@@ -1247,9 +1301,11 @@ export class Assistant {
1247
1301
  parts: output.flatMap((b) => b.type === 'message' ? b.content.map((c) => c.type) : []),
1248
1302
  }));
1249
1303
  if (!answer)
1250
- throw new AssistantError(res.incomplete_details?.reason
1251
- ? `OpenAI stopped before answering (${res.incomplete_details.reason}).`
1252
- : 'OpenAI sent no answer.', 'upstream');
1304
+ throw new AssistantError(res.incomplete_details?.reason === 'max_output_tokens'
1305
+ ? 'OpenAI spent the whole answer thinking and wrote nothing. Try again, or pick a smaller tier.'
1306
+ : res.incomplete_details?.reason
1307
+ ? `OpenAI stopped before answering (${res.incomplete_details.reason}).`
1308
+ : 'OpenAI sent no answer.', 'upstream');
1253
1309
  }
1254
1310
  const cut = res.incomplete_details?.reason === 'max_output_tokens' && (words || hasCall)
1255
1311
  ? ' …(cut)'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@columenlabs/lumen-ai",
3
- "version": "0.1.99",
3
+ "version": "0.1.101",
4
4
  "description": "Lumen's assistant and agent parts: a chat assistant's server (Claude tiers, tools over MCP, the web, hand off, spend caps), serving an MCP server from a Worker, read-only tool sets and metered tool calls.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,8 +41,8 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@anthropic-ai/sdk": "0.133.0",
44
- "@columenlabs/lumen-ops": "0.1.99",
45
- "@columenlabs/lumen-ui-types": "0.1.99",
44
+ "@columenlabs/lumen-ops": "0.1.101",
45
+ "@columenlabs/lumen-ui-types": "0.1.101",
46
46
  "openai": "7.32.0",
47
47
  "zod": "^4.3.6"
48
48
  },