@openhoo/hoopilot 0.7.3 → 0.7.5

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/dist/index.cjs CHANGED
@@ -399,22 +399,31 @@ function normalizeCopilotUsage(body) {
399
399
  }
400
400
  function normalizeQuotaDetail(detail) {
401
401
  const entitlement = numberOrUndefined(detail.entitlement);
402
+ const overageCount = numberOrUndefined(detail.overage_count);
402
403
  const remaining = numberOrUndefined(detail.remaining) ?? numberOrUndefined(detail.quota_remaining);
403
404
  return removeUndefinedQuota({
404
405
  entitlement,
405
- overageCount: numberOrUndefined(detail.overage_count),
406
+ hasQuota: typeof detail.has_quota === "boolean" ? detail.has_quota : void 0,
407
+ overageCount,
408
+ overageEntitlement: numberOrUndefined(detail.overage_entitlement),
406
409
  overagePermitted: typeof detail.overage_permitted === "boolean" ? detail.overage_permitted : void 0,
407
410
  percentRemaining: numberOrUndefined(detail.percent_remaining),
411
+ quotaId: stringOrUndefined(detail.quota_id),
412
+ quotaResetAt: stringOrUndefined(detail.quota_reset_at),
408
413
  remaining,
414
+ timestampUtc: stringOrUndefined(detail.timestamp_utc),
415
+ tokenBasedBilling: typeof detail.token_based_billing === "boolean" ? detail.token_based_billing : void 0,
409
416
  unlimited: typeof detail.unlimited === "boolean" ? detail.unlimited : void 0,
410
- used: usedFrom(entitlement, remaining)
417
+ used: usedFrom(entitlement, remaining, overageCount)
411
418
  });
412
419
  }
413
- function usedFrom(entitlement, remaining) {
420
+ function usedFrom(entitlement, remaining, overageCount) {
414
421
  if (entitlement === void 0 || remaining === void 0) {
415
422
  return void 0;
416
423
  }
417
- return Math.max(0, entitlement - remaining);
424
+ const base = entitlement - remaining;
425
+ const overage = remaining === 0 ? overageCount ?? 0 : 0;
426
+ return Math.max(0, base + overage);
418
427
  }
419
428
  function numberOrUndefined(value) {
420
429
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
@@ -677,6 +686,12 @@ function isLogLevel(value) {
677
686
 
678
687
  // src/openai.ts
679
688
  var DEFAULT_MODEL = "gpt-4.1";
689
+ var OpenAICompatibilityError = class extends Error {
690
+ constructor(message) {
691
+ super(message);
692
+ this.name = "OpenAICompatibilityError";
693
+ }
694
+ };
680
695
  function responsesRequestToChatCompletion(request) {
681
696
  const messages = [];
682
697
  const instructions = contentToText(request.instructions);
@@ -710,13 +725,22 @@ function normalizeChatCompletionRequest(request) {
710
725
  });
711
726
  }
712
727
  function completionsRequestToChatCompletion(request) {
728
+ assertSupportedLegacyCompletionRequest(request);
713
729
  return removeUndefined({
730
+ frequency_penalty: request.frequency_penalty,
731
+ logit_bias: request.logit_bias,
714
732
  max_tokens: request.max_tokens,
715
- messages: [{ content: promptToText(request.prompt), role: "user" }],
733
+ messages: [{ content: legacyPromptToText(request.prompt), role: "user" }],
716
734
  model: normalizeRequestedModel(request.model),
735
+ n: request.n,
736
+ presence_penalty: request.presence_penalty,
737
+ seed: request.seed,
738
+ stop: request.stop,
717
739
  stream: request.stream === true,
740
+ stream_options: request.stream_options,
718
741
  temperature: request.temperature,
719
- top_p: request.top_p
742
+ top_p: request.top_p,
743
+ user: request.user
720
744
  });
721
745
  }
722
746
  function normalizeRequestedModel(model) {
@@ -752,21 +776,21 @@ function chatCompletionToResponse(completion, responseId) {
752
776
  });
753
777
  }
754
778
  function chatCompletionToCompletion(completion) {
755
- const choice = firstChoice(completion);
756
- const message = asRecord(choice.message);
757
779
  return removeUndefined({
758
- choices: [
759
- {
780
+ choices: completionChoices(completion).map((choice, index) => {
781
+ const message = asRecord(choice.message);
782
+ return {
760
783
  finish_reason: choice.finish_reason ?? "stop",
761
- index: 0,
762
- logprobs: null,
763
- text: contentToText(message.content)
764
- }
765
- ],
784
+ index: typeof choice.index === "number" ? choice.index : index,
785
+ logprobs: choice.logprobs ?? null,
786
+ text: contentToText(choice.text) || contentToText(message.content)
787
+ };
788
+ }),
766
789
  created: completion.created ?? epochSeconds(),
767
790
  id: completion.id ?? `cmpl_${randomId()}`,
768
791
  model: completion.model ?? DEFAULT_MODEL,
769
792
  object: "text_completion",
793
+ system_fingerprint: completion.system_fingerprint,
770
794
  usage: completion.usage
771
795
  });
772
796
  }
@@ -1030,7 +1054,8 @@ function inputToMessages(input) {
1030
1054
  const messages = [];
1031
1055
  for (const item of input) {
1032
1056
  const record = asRecord(item);
1033
- if (record.type === "function_call_output") {
1057
+ const type = contentToText(record.type);
1058
+ if (type === "function_call_output") {
1034
1059
  messages.push({
1035
1060
  content: contentToText(record.output),
1036
1061
  role: "tool",
@@ -1038,7 +1063,7 @@ function inputToMessages(input) {
1038
1063
  });
1039
1064
  continue;
1040
1065
  }
1041
- if (record.type === "function_call") {
1066
+ if (type === "function_call") {
1042
1067
  messages.push({
1043
1068
  role: "assistant",
1044
1069
  tool_calls: [
@@ -1054,7 +1079,10 @@ function inputToMessages(input) {
1054
1079
  });
1055
1080
  continue;
1056
1081
  }
1057
- const role = roleToChatRole(contentToText(record.role));
1082
+ if (type && type !== "message") {
1083
+ unsupportedResponsesFeature(`input item type "${type}"`);
1084
+ }
1085
+ const role = responsesRoleToChatRole(contentToText(record.role));
1058
1086
  const content = chatMessageContent(record.content);
1059
1087
  if (role && content !== void 0) {
1060
1088
  messages.push({ content, role });
@@ -1067,7 +1095,10 @@ function chatMessageContent(content) {
1067
1095
  return content;
1068
1096
  }
1069
1097
  if (!Array.isArray(content)) {
1070
- return contentToText(content) || void 0;
1098
+ if (content === void 0 || content === null) {
1099
+ return void 0;
1100
+ }
1101
+ unsupportedResponsesFeature("non-array message content objects");
1071
1102
  }
1072
1103
  const parts = [];
1073
1104
  for (const part of content) {
@@ -1075,13 +1106,31 @@ function chatMessageContent(content) {
1075
1106
  const type = contentToText(record.type);
1076
1107
  if (type === "input_text" || type === "output_text" || type === "text") {
1077
1108
  parts.push({ text: contentToText(record.text), type: "text" });
1109
+ continue;
1078
1110
  }
1079
1111
  if (type === "input_image") {
1112
+ if (contentToText(record.file_id)) {
1113
+ unsupportedResponsesFeature("input_image file_id parts");
1114
+ }
1080
1115
  const imageUrl = contentToText(record.image_url);
1081
- if (imageUrl) {
1082
- parts.push({ image_url: { url: imageUrl }, type: "image_url" });
1116
+ if (!imageUrl) {
1117
+ unsupportedResponsesFeature("input_image parts without image_url");
1118
+ }
1119
+ const image = { url: imageUrl };
1120
+ const detail = contentToText(record.detail);
1121
+ if (detail) {
1122
+ image.detail = detail;
1083
1123
  }
1124
+ parts.push({ image_url: image, type: "image_url" });
1125
+ continue;
1126
+ }
1127
+ if (type === "input_file") {
1128
+ unsupportedResponsesFeature("input_file parts");
1129
+ }
1130
+ if (type === "input_audio") {
1131
+ unsupportedResponsesFeature("input_audio parts");
1084
1132
  }
1133
+ unsupportedResponsesFeature(`content part type "${type || "unknown"}"`);
1085
1134
  }
1086
1135
  if (parts.length === 0) {
1087
1136
  return void 0;
@@ -1091,11 +1140,38 @@ function chatMessageContent(content) {
1091
1140
  }
1092
1141
  return parts;
1093
1142
  }
1094
- function promptToText(prompt) {
1095
- if (Array.isArray(prompt)) {
1096
- return prompt.map((item) => contentToText(item)).join("\n");
1143
+ function legacyPromptToText(prompt) {
1144
+ if (typeof prompt === "string") {
1145
+ return prompt;
1146
+ }
1147
+ if (Array.isArray(prompt) && prompt.length === 1 && typeof prompt[0] === "string") {
1148
+ return prompt[0];
1149
+ }
1150
+ throw new OpenAICompatibilityError(
1151
+ "Hoopilot legacy completions compatibility supports exactly one string prompt per request."
1152
+ );
1153
+ }
1154
+ function assertSupportedLegacyCompletionRequest(request) {
1155
+ if (request.echo === true) {
1156
+ throw new OpenAICompatibilityError(
1157
+ "Hoopilot legacy completions compatibility does not support echo=true."
1158
+ );
1159
+ }
1160
+ if (typeof request.best_of === "number" && request.best_of > 1) {
1161
+ throw new OpenAICompatibilityError(
1162
+ "Hoopilot legacy completions compatibility does not support best_of greater than 1."
1163
+ );
1164
+ }
1165
+ if (typeof request.logprobs === "number" && request.logprobs > 0) {
1166
+ throw new OpenAICompatibilityError(
1167
+ "Hoopilot legacy completions compatibility does not support legacy logprobs."
1168
+ );
1169
+ }
1170
+ if (contentToText(request.suffix)) {
1171
+ throw new OpenAICompatibilityError(
1172
+ "Hoopilot legacy completions compatibility does not support suffix."
1173
+ );
1097
1174
  }
1098
- return contentToText(prompt);
1099
1175
  }
1100
1176
  function contentToText(content) {
1101
1177
  if (typeof content === "string") {
@@ -1119,25 +1195,35 @@ function contentToText(content) {
1119
1195
  }
1120
1196
  return "";
1121
1197
  }
1122
- function roleToChatRole(role) {
1123
- if (role === "assistant" || role === "developer" || role === "system" || role === "tool") {
1198
+ function responsesRoleToChatRole(role) {
1199
+ if (!role) {
1200
+ return "user";
1201
+ }
1202
+ if (role === "assistant" || role === "developer" || role === "system" || role === "tool" || role === "user") {
1124
1203
  return role === "developer" ? "system" : role;
1125
1204
  }
1126
- return "user";
1205
+ unsupportedResponsesFeature(`message role "${role}"`);
1127
1206
  }
1128
1207
  function chatTools(tools) {
1129
1208
  if (!Array.isArray(tools)) {
1130
1209
  return void 0;
1131
1210
  }
1132
- const converted = tools.map((tool) => asRecord(tool)).filter((tool) => tool.type === "function").map((tool) => ({
1133
- function: removeUndefined({
1134
- description: tool.description,
1135
- name: tool.name,
1136
- parameters: tool.parameters,
1137
- strict: tool.strict
1138
- }),
1139
- type: "function"
1140
- }));
1211
+ const converted = tools.map((tool) => {
1212
+ const record = asRecord(tool);
1213
+ const type = contentToText(record.type);
1214
+ if (type !== "function") {
1215
+ unsupportedResponsesFeature(`tool type "${type || "unknown"}"`);
1216
+ }
1217
+ return {
1218
+ function: removeUndefined({
1219
+ description: record.description,
1220
+ name: record.name,
1221
+ parameters: record.parameters,
1222
+ strict: record.strict
1223
+ }),
1224
+ type: "function"
1225
+ };
1226
+ });
1141
1227
  return converted.length > 0 ? converted : void 0;
1142
1228
  }
1143
1229
  function chatToolChoice(toolChoice) {
@@ -1145,10 +1231,16 @@ function chatToolChoice(toolChoice) {
1145
1231
  return toolChoice;
1146
1232
  }
1147
1233
  const record = asRecord(toolChoice);
1148
- if (record.type === "function" && typeof record.name === "string") {
1234
+ const type = contentToText(record.type);
1235
+ if (type === "function" && typeof record.name === "string") {
1149
1236
  return { function: { name: record.name }, type: "function" };
1150
1237
  }
1151
- return toolChoice;
1238
+ unsupportedResponsesFeature(`tool_choice type "${type || "unknown"}"`);
1239
+ }
1240
+ function unsupportedResponsesFeature(feature) {
1241
+ throw new OpenAICompatibilityError(
1242
+ `Hoopilot Responses-to-chat compatibility does not support ${feature}.`
1243
+ );
1152
1244
  }
1153
1245
  function outputItemsFromMessage(message) {
1154
1246
  const output = [];
@@ -1263,8 +1355,11 @@ function firstNumber(...values) {
1263
1355
  return void 0;
1264
1356
  }
1265
1357
  function firstChoice(completion) {
1358
+ return completionChoices(completion)[0] ?? {};
1359
+ }
1360
+ function completionChoices(completion) {
1266
1361
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
1267
- return asRecord(choices[0]);
1362
+ return choices.map((choice) => asRecord(choice));
1268
1363
  }
1269
1364
  function processCompletionSseBlock(block, enqueue, markTerminal) {
1270
1365
  let event = "message";
@@ -1296,25 +1391,28 @@ function processCompletionSseBlock(block, enqueue, markTerminal) {
1296
1391
  enqueue({ error });
1297
1392
  return;
1298
1393
  }
1299
- const choice = firstChoice(parsed);
1300
- const delta = asRecord(choice.delta);
1301
- const text = contentToText(delta.content);
1302
- const finishReason = choice.finish_reason ?? null;
1394
+ const choices = completionChoices(parsed).map((choice, index) => {
1395
+ const delta = asRecord(choice.delta);
1396
+ const text = contentToText(delta.content);
1397
+ const finishReason = choice.finish_reason ?? null;
1398
+ if (!text && finishReason === null) {
1399
+ return void 0;
1400
+ }
1401
+ return {
1402
+ finish_reason: finishReason,
1403
+ index: typeof choice.index === "number" ? choice.index : index,
1404
+ logprobs: choice.logprobs ?? null,
1405
+ text
1406
+ };
1407
+ }).filter((choice) => choice !== void 0);
1303
1408
  const usage = asRecord(parsed.usage);
1304
1409
  const hasUsage = Object.keys(usage).length > 0;
1305
- if (!text && finishReason === null && !hasUsage) {
1410
+ if (choices.length === 0 && !hasUsage) {
1306
1411
  return;
1307
1412
  }
1308
1413
  enqueue(
1309
1414
  removeUndefined({
1310
- choices: text || finishReason !== null ? [
1311
- {
1312
- finish_reason: finishReason,
1313
- index: typeof choice.index === "number" ? choice.index : 0,
1314
- logprobs: null,
1315
- text
1316
- }
1317
- ] : [],
1415
+ choices,
1318
1416
  created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
1319
1417
  id: contentToText(parsed.id) || `cmpl_${randomId()}`,
1320
1418
  model: contentToText(parsed.model) || DEFAULT_MODEL,
@@ -1623,11 +1721,43 @@ var MetricsRegistry = class {
1623
1721
  gauge("remaining", "Remaining quota for the Copilot category.", (q) => q.remaining);
1624
1722
  gauge("entitlement", "Quota entitlement for the Copilot category.", (q) => q.entitlement);
1625
1723
  gauge("used", "Used quota (entitlement minus remaining) for the category.", (q) => q.used);
1724
+ gauge("overage_count", "Overage count for the Copilot category.", (q) => q.overageCount);
1725
+ gauge(
1726
+ "overage_entitlement",
1727
+ "Overage entitlement for the Copilot category.",
1728
+ (q) => q.overageEntitlement
1729
+ );
1626
1730
  gauge(
1627
1731
  "percent_remaining",
1628
1732
  "Percent of quota remaining for the Copilot category.",
1629
1733
  (q) => q.percentRemaining
1630
1734
  );
1735
+ booleanGauge(
1736
+ "unlimited",
1737
+ "Whether the Copilot quota category is unlimited.",
1738
+ (q) => q.unlimited
1739
+ );
1740
+ booleanGauge(
1741
+ "overage_permitted",
1742
+ "Whether overage is permitted for the Copilot category.",
1743
+ (q) => q.overagePermitted
1744
+ );
1745
+ booleanGauge("has_quota", "Whether the Copilot quota category has a quota.", (q) => q.hasQuota);
1746
+ booleanGauge(
1747
+ "token_based_billing",
1748
+ "Whether the Copilot quota category uses token-based billing.",
1749
+ (q) => q.tokenBasedBilling
1750
+ );
1751
+ dateGauge(
1752
+ "category_reset_timestamp_seconds",
1753
+ "Unix epoch of the Copilot category-specific quota reset.",
1754
+ (q) => q.quotaResetAt
1755
+ );
1756
+ dateGauge(
1757
+ "category_snapshot_timestamp_seconds",
1758
+ "Unix epoch of the Copilot category quota snapshot.",
1759
+ (q) => q.timestampUtc
1760
+ );
1631
1761
  const resetMs = usage.quotaResetDate ? Date.parse(usage.quotaResetDate) : Number.NaN;
1632
1762
  if (Number.isFinite(resetMs)) {
1633
1763
  lines.push(
@@ -1646,6 +1776,30 @@ var MetricsRegistry = class {
1646
1776
  })} 1`
1647
1777
  );
1648
1778
  }
1779
+ function booleanGauge(suffix, help, pick) {
1780
+ const present = categories.filter(([, quota]) => pick(quota) !== void 0);
1781
+ if (present.length === 0) {
1782
+ return;
1783
+ }
1784
+ lines.push(`# HELP hoopilot_copilot_quota_${suffix} ${help}`);
1785
+ lines.push(`# TYPE hoopilot_copilot_quota_${suffix} gauge`);
1786
+ for (const [category, quota] of present) {
1787
+ lines.push(
1788
+ `hoopilot_copilot_quota_${suffix}${labels({ category })} ${pick(quota) ? 1 : 0}`
1789
+ );
1790
+ }
1791
+ }
1792
+ function dateGauge(suffix, help, pick) {
1793
+ const present = categories.map(([category, quota]) => [category, Date.parse(pick(quota) ?? "")]).filter(([, timestamp]) => Number.isFinite(timestamp));
1794
+ if (present.length === 0) {
1795
+ return;
1796
+ }
1797
+ lines.push(`# HELP hoopilot_copilot_quota_${suffix} ${help}`);
1798
+ lines.push(`# TYPE hoopilot_copilot_quota_${suffix} gauge`);
1799
+ for (const [category, timestamp] of present) {
1800
+ lines.push(`hoopilot_copilot_quota_${suffix}${labels({ category })} ${timestamp / 1e3}`);
1801
+ }
1802
+ }
1649
1803
  }
1650
1804
  };
1651
1805
  function observeResponseUsage(response, fallbackModel, onUsage, signal) {
@@ -1886,6 +2040,12 @@ function createHoopilotHandler(options = {}) {
1886
2040
  "request body was invalid json"
1887
2041
  );
1888
2042
  return finish(jsonError(400, "invalid_request_error", message));
2043
+ } else if (error instanceof OpenAICompatibilityError) {
2044
+ requestLogger.warn(
2045
+ { err: errorDetails(error), event: "http.request.failed" },
2046
+ "request body used unsupported OpenAI compatibility fields"
2047
+ );
2048
+ return finish(jsonError(400, "invalid_request_error", message));
1889
2049
  } else if (error instanceof RequestBodyTooLargeError) {
1890
2050
  requestLogger.warn(
1891
2051
  { err: errorDetails(error), event: "http.request.failed" },
@@ -2337,8 +2497,8 @@ function metricsResponse(metrics) {
2337
2497
  });
2338
2498
  }
2339
2499
  async function handleUsage(metrics, readUsage, signal) {
2340
- const proxy = metrics.snapshot();
2341
2500
  const { copilot, error } = await readUsage(signal);
2501
+ const proxy = metrics.snapshot();
2342
2502
  const body = { copilot: copilot ?? null, object: "usage", proxy };
2343
2503
  if (error) {
2344
2504
  body.copilot_error = error;
@@ -2363,10 +2523,10 @@ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_
2363
2523
  metrics.recordCopilotQuota(value);
2364
2524
  return { copilot: value };
2365
2525
  } catch (error) {
2366
- metrics.recordUpstream(usagePath, false);
2367
2526
  if (error instanceof CopilotAuthError) {
2368
2527
  return { error: error.message };
2369
2528
  }
2529
+ metrics.recordUpstream(usagePath, false);
2370
2530
  return { error: errorMessage(error) };
2371
2531
  }
2372
2532
  };