@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.d.cts CHANGED
@@ -126,10 +126,16 @@ interface RequestObservation {
126
126
  /** One quota category (chat, completions, or premium_interactions/credits). */
127
127
  interface CopilotQuota {
128
128
  entitlement?: number;
129
+ hasQuota?: boolean;
129
130
  overageCount?: number;
131
+ overageEntitlement?: number;
130
132
  overagePermitted?: boolean;
131
133
  percentRemaining?: number;
134
+ quotaId?: string;
135
+ quotaResetAt?: string;
132
136
  remaining?: number;
137
+ timestampUtc?: string;
138
+ tokenBasedBilling?: boolean;
133
139
  unlimited?: boolean;
134
140
  used?: number;
135
141
  }
package/dist/index.d.ts CHANGED
@@ -126,10 +126,16 @@ interface RequestObservation {
126
126
  /** One quota category (chat, completions, or premium_interactions/credits). */
127
127
  interface CopilotQuota {
128
128
  entitlement?: number;
129
+ hasQuota?: boolean;
129
130
  overageCount?: number;
131
+ overageEntitlement?: number;
130
132
  overagePermitted?: boolean;
131
133
  percentRemaining?: number;
134
+ quotaId?: string;
135
+ quotaResetAt?: string;
132
136
  remaining?: number;
137
+ timestampUtc?: string;
138
+ tokenBasedBilling?: boolean;
133
139
  unlimited?: boolean;
134
140
  used?: number;
135
141
  }
package/dist/index.js CHANGED
@@ -329,22 +329,31 @@ function normalizeCopilotUsage(body) {
329
329
  }
330
330
  function normalizeQuotaDetail(detail) {
331
331
  const entitlement = numberOrUndefined(detail.entitlement);
332
+ const overageCount = numberOrUndefined(detail.overage_count);
332
333
  const remaining = numberOrUndefined(detail.remaining) ?? numberOrUndefined(detail.quota_remaining);
333
334
  return removeUndefinedQuota({
334
335
  entitlement,
335
- overageCount: numberOrUndefined(detail.overage_count),
336
+ hasQuota: typeof detail.has_quota === "boolean" ? detail.has_quota : void 0,
337
+ overageCount,
338
+ overageEntitlement: numberOrUndefined(detail.overage_entitlement),
336
339
  overagePermitted: typeof detail.overage_permitted === "boolean" ? detail.overage_permitted : void 0,
337
340
  percentRemaining: numberOrUndefined(detail.percent_remaining),
341
+ quotaId: stringOrUndefined(detail.quota_id),
342
+ quotaResetAt: stringOrUndefined(detail.quota_reset_at),
338
343
  remaining,
344
+ timestampUtc: stringOrUndefined(detail.timestamp_utc),
345
+ tokenBasedBilling: typeof detail.token_based_billing === "boolean" ? detail.token_based_billing : void 0,
339
346
  unlimited: typeof detail.unlimited === "boolean" ? detail.unlimited : void 0,
340
- used: usedFrom(entitlement, remaining)
347
+ used: usedFrom(entitlement, remaining, overageCount)
341
348
  });
342
349
  }
343
- function usedFrom(entitlement, remaining) {
350
+ function usedFrom(entitlement, remaining, overageCount) {
344
351
  if (entitlement === void 0 || remaining === void 0) {
345
352
  return void 0;
346
353
  }
347
- return Math.max(0, entitlement - remaining);
354
+ const base = entitlement - remaining;
355
+ const overage = remaining === 0 ? overageCount ?? 0 : 0;
356
+ return Math.max(0, base + overage);
348
357
  }
349
358
  function numberOrUndefined(value) {
350
359
  return typeof value === "number" && Number.isFinite(value) ? value : void 0;
@@ -607,6 +616,12 @@ function isLogLevel(value) {
607
616
 
608
617
  // src/openai.ts
609
618
  var DEFAULT_MODEL = "gpt-4.1";
619
+ var OpenAICompatibilityError = class extends Error {
620
+ constructor(message) {
621
+ super(message);
622
+ this.name = "OpenAICompatibilityError";
623
+ }
624
+ };
610
625
  function responsesRequestToChatCompletion(request) {
611
626
  const messages = [];
612
627
  const instructions = contentToText(request.instructions);
@@ -640,13 +655,22 @@ function normalizeChatCompletionRequest(request) {
640
655
  });
641
656
  }
642
657
  function completionsRequestToChatCompletion(request) {
658
+ assertSupportedLegacyCompletionRequest(request);
643
659
  return removeUndefined({
660
+ frequency_penalty: request.frequency_penalty,
661
+ logit_bias: request.logit_bias,
644
662
  max_tokens: request.max_tokens,
645
- messages: [{ content: promptToText(request.prompt), role: "user" }],
663
+ messages: [{ content: legacyPromptToText(request.prompt), role: "user" }],
646
664
  model: normalizeRequestedModel(request.model),
665
+ n: request.n,
666
+ presence_penalty: request.presence_penalty,
667
+ seed: request.seed,
668
+ stop: request.stop,
647
669
  stream: request.stream === true,
670
+ stream_options: request.stream_options,
648
671
  temperature: request.temperature,
649
- top_p: request.top_p
672
+ top_p: request.top_p,
673
+ user: request.user
650
674
  });
651
675
  }
652
676
  function normalizeRequestedModel(model) {
@@ -682,21 +706,21 @@ function chatCompletionToResponse(completion, responseId) {
682
706
  });
683
707
  }
684
708
  function chatCompletionToCompletion(completion) {
685
- const choice = firstChoice(completion);
686
- const message = asRecord(choice.message);
687
709
  return removeUndefined({
688
- choices: [
689
- {
710
+ choices: completionChoices(completion).map((choice, index) => {
711
+ const message = asRecord(choice.message);
712
+ return {
690
713
  finish_reason: choice.finish_reason ?? "stop",
691
- index: 0,
692
- logprobs: null,
693
- text: contentToText(message.content)
694
- }
695
- ],
714
+ index: typeof choice.index === "number" ? choice.index : index,
715
+ logprobs: choice.logprobs ?? null,
716
+ text: contentToText(choice.text) || contentToText(message.content)
717
+ };
718
+ }),
696
719
  created: completion.created ?? epochSeconds(),
697
720
  id: completion.id ?? `cmpl_${randomId()}`,
698
721
  model: completion.model ?? DEFAULT_MODEL,
699
722
  object: "text_completion",
723
+ system_fingerprint: completion.system_fingerprint,
700
724
  usage: completion.usage
701
725
  });
702
726
  }
@@ -960,7 +984,8 @@ function inputToMessages(input) {
960
984
  const messages = [];
961
985
  for (const item of input) {
962
986
  const record = asRecord(item);
963
- if (record.type === "function_call_output") {
987
+ const type = contentToText(record.type);
988
+ if (type === "function_call_output") {
964
989
  messages.push({
965
990
  content: contentToText(record.output),
966
991
  role: "tool",
@@ -968,7 +993,7 @@ function inputToMessages(input) {
968
993
  });
969
994
  continue;
970
995
  }
971
- if (record.type === "function_call") {
996
+ if (type === "function_call") {
972
997
  messages.push({
973
998
  role: "assistant",
974
999
  tool_calls: [
@@ -984,7 +1009,10 @@ function inputToMessages(input) {
984
1009
  });
985
1010
  continue;
986
1011
  }
987
- const role = roleToChatRole(contentToText(record.role));
1012
+ if (type && type !== "message") {
1013
+ unsupportedResponsesFeature(`input item type "${type}"`);
1014
+ }
1015
+ const role = responsesRoleToChatRole(contentToText(record.role));
988
1016
  const content = chatMessageContent(record.content);
989
1017
  if (role && content !== void 0) {
990
1018
  messages.push({ content, role });
@@ -997,7 +1025,10 @@ function chatMessageContent(content) {
997
1025
  return content;
998
1026
  }
999
1027
  if (!Array.isArray(content)) {
1000
- return contentToText(content) || void 0;
1028
+ if (content === void 0 || content === null) {
1029
+ return void 0;
1030
+ }
1031
+ unsupportedResponsesFeature("non-array message content objects");
1001
1032
  }
1002
1033
  const parts = [];
1003
1034
  for (const part of content) {
@@ -1005,13 +1036,31 @@ function chatMessageContent(content) {
1005
1036
  const type = contentToText(record.type);
1006
1037
  if (type === "input_text" || type === "output_text" || type === "text") {
1007
1038
  parts.push({ text: contentToText(record.text), type: "text" });
1039
+ continue;
1008
1040
  }
1009
1041
  if (type === "input_image") {
1042
+ if (contentToText(record.file_id)) {
1043
+ unsupportedResponsesFeature("input_image file_id parts");
1044
+ }
1010
1045
  const imageUrl = contentToText(record.image_url);
1011
- if (imageUrl) {
1012
- parts.push({ image_url: { url: imageUrl }, type: "image_url" });
1046
+ if (!imageUrl) {
1047
+ unsupportedResponsesFeature("input_image parts without image_url");
1048
+ }
1049
+ const image = { url: imageUrl };
1050
+ const detail = contentToText(record.detail);
1051
+ if (detail) {
1052
+ image.detail = detail;
1013
1053
  }
1054
+ parts.push({ image_url: image, type: "image_url" });
1055
+ continue;
1056
+ }
1057
+ if (type === "input_file") {
1058
+ unsupportedResponsesFeature("input_file parts");
1059
+ }
1060
+ if (type === "input_audio") {
1061
+ unsupportedResponsesFeature("input_audio parts");
1014
1062
  }
1063
+ unsupportedResponsesFeature(`content part type "${type || "unknown"}"`);
1015
1064
  }
1016
1065
  if (parts.length === 0) {
1017
1066
  return void 0;
@@ -1021,11 +1070,38 @@ function chatMessageContent(content) {
1021
1070
  }
1022
1071
  return parts;
1023
1072
  }
1024
- function promptToText(prompt) {
1025
- if (Array.isArray(prompt)) {
1026
- return prompt.map((item) => contentToText(item)).join("\n");
1073
+ function legacyPromptToText(prompt) {
1074
+ if (typeof prompt === "string") {
1075
+ return prompt;
1076
+ }
1077
+ if (Array.isArray(prompt) && prompt.length === 1 && typeof prompt[0] === "string") {
1078
+ return prompt[0];
1079
+ }
1080
+ throw new OpenAICompatibilityError(
1081
+ "Hoopilot legacy completions compatibility supports exactly one string prompt per request."
1082
+ );
1083
+ }
1084
+ function assertSupportedLegacyCompletionRequest(request) {
1085
+ if (request.echo === true) {
1086
+ throw new OpenAICompatibilityError(
1087
+ "Hoopilot legacy completions compatibility does not support echo=true."
1088
+ );
1089
+ }
1090
+ if (typeof request.best_of === "number" && request.best_of > 1) {
1091
+ throw new OpenAICompatibilityError(
1092
+ "Hoopilot legacy completions compatibility does not support best_of greater than 1."
1093
+ );
1094
+ }
1095
+ if (typeof request.logprobs === "number" && request.logprobs > 0) {
1096
+ throw new OpenAICompatibilityError(
1097
+ "Hoopilot legacy completions compatibility does not support legacy logprobs."
1098
+ );
1099
+ }
1100
+ if (contentToText(request.suffix)) {
1101
+ throw new OpenAICompatibilityError(
1102
+ "Hoopilot legacy completions compatibility does not support suffix."
1103
+ );
1027
1104
  }
1028
- return contentToText(prompt);
1029
1105
  }
1030
1106
  function contentToText(content) {
1031
1107
  if (typeof content === "string") {
@@ -1049,25 +1125,35 @@ function contentToText(content) {
1049
1125
  }
1050
1126
  return "";
1051
1127
  }
1052
- function roleToChatRole(role) {
1053
- if (role === "assistant" || role === "developer" || role === "system" || role === "tool") {
1128
+ function responsesRoleToChatRole(role) {
1129
+ if (!role) {
1130
+ return "user";
1131
+ }
1132
+ if (role === "assistant" || role === "developer" || role === "system" || role === "tool" || role === "user") {
1054
1133
  return role === "developer" ? "system" : role;
1055
1134
  }
1056
- return "user";
1135
+ unsupportedResponsesFeature(`message role "${role}"`);
1057
1136
  }
1058
1137
  function chatTools(tools) {
1059
1138
  if (!Array.isArray(tools)) {
1060
1139
  return void 0;
1061
1140
  }
1062
- const converted = tools.map((tool) => asRecord(tool)).filter((tool) => tool.type === "function").map((tool) => ({
1063
- function: removeUndefined({
1064
- description: tool.description,
1065
- name: tool.name,
1066
- parameters: tool.parameters,
1067
- strict: tool.strict
1068
- }),
1069
- type: "function"
1070
- }));
1141
+ const converted = tools.map((tool) => {
1142
+ const record = asRecord(tool);
1143
+ const type = contentToText(record.type);
1144
+ if (type !== "function") {
1145
+ unsupportedResponsesFeature(`tool type "${type || "unknown"}"`);
1146
+ }
1147
+ return {
1148
+ function: removeUndefined({
1149
+ description: record.description,
1150
+ name: record.name,
1151
+ parameters: record.parameters,
1152
+ strict: record.strict
1153
+ }),
1154
+ type: "function"
1155
+ };
1156
+ });
1071
1157
  return converted.length > 0 ? converted : void 0;
1072
1158
  }
1073
1159
  function chatToolChoice(toolChoice) {
@@ -1075,10 +1161,16 @@ function chatToolChoice(toolChoice) {
1075
1161
  return toolChoice;
1076
1162
  }
1077
1163
  const record = asRecord(toolChoice);
1078
- if (record.type === "function" && typeof record.name === "string") {
1164
+ const type = contentToText(record.type);
1165
+ if (type === "function" && typeof record.name === "string") {
1079
1166
  return { function: { name: record.name }, type: "function" };
1080
1167
  }
1081
- return toolChoice;
1168
+ unsupportedResponsesFeature(`tool_choice type "${type || "unknown"}"`);
1169
+ }
1170
+ function unsupportedResponsesFeature(feature) {
1171
+ throw new OpenAICompatibilityError(
1172
+ `Hoopilot Responses-to-chat compatibility does not support ${feature}.`
1173
+ );
1082
1174
  }
1083
1175
  function outputItemsFromMessage(message) {
1084
1176
  const output = [];
@@ -1193,8 +1285,11 @@ function firstNumber(...values) {
1193
1285
  return void 0;
1194
1286
  }
1195
1287
  function firstChoice(completion) {
1288
+ return completionChoices(completion)[0] ?? {};
1289
+ }
1290
+ function completionChoices(completion) {
1196
1291
  const choices = Array.isArray(completion.choices) ? completion.choices : [];
1197
- return asRecord(choices[0]);
1292
+ return choices.map((choice) => asRecord(choice));
1198
1293
  }
1199
1294
  function processCompletionSseBlock(block, enqueue, markTerminal) {
1200
1295
  let event = "message";
@@ -1226,25 +1321,28 @@ function processCompletionSseBlock(block, enqueue, markTerminal) {
1226
1321
  enqueue({ error });
1227
1322
  return;
1228
1323
  }
1229
- const choice = firstChoice(parsed);
1230
- const delta = asRecord(choice.delta);
1231
- const text = contentToText(delta.content);
1232
- const finishReason = choice.finish_reason ?? null;
1324
+ const choices = completionChoices(parsed).map((choice, index) => {
1325
+ const delta = asRecord(choice.delta);
1326
+ const text = contentToText(delta.content);
1327
+ const finishReason = choice.finish_reason ?? null;
1328
+ if (!text && finishReason === null) {
1329
+ return void 0;
1330
+ }
1331
+ return {
1332
+ finish_reason: finishReason,
1333
+ index: typeof choice.index === "number" ? choice.index : index,
1334
+ logprobs: choice.logprobs ?? null,
1335
+ text
1336
+ };
1337
+ }).filter((choice) => choice !== void 0);
1233
1338
  const usage = asRecord(parsed.usage);
1234
1339
  const hasUsage = Object.keys(usage).length > 0;
1235
- if (!text && finishReason === null && !hasUsage) {
1340
+ if (choices.length === 0 && !hasUsage) {
1236
1341
  return;
1237
1342
  }
1238
1343
  enqueue(
1239
1344
  removeUndefined({
1240
- choices: text || finishReason !== null ? [
1241
- {
1242
- finish_reason: finishReason,
1243
- index: typeof choice.index === "number" ? choice.index : 0,
1244
- logprobs: null,
1245
- text
1246
- }
1247
- ] : [],
1345
+ choices,
1248
1346
  created: typeof parsed.created === "number" ? parsed.created : epochSeconds(),
1249
1347
  id: contentToText(parsed.id) || `cmpl_${randomId()}`,
1250
1348
  model: contentToText(parsed.model) || DEFAULT_MODEL,
@@ -1553,11 +1651,43 @@ var MetricsRegistry = class {
1553
1651
  gauge("remaining", "Remaining quota for the Copilot category.", (q) => q.remaining);
1554
1652
  gauge("entitlement", "Quota entitlement for the Copilot category.", (q) => q.entitlement);
1555
1653
  gauge("used", "Used quota (entitlement minus remaining) for the category.", (q) => q.used);
1654
+ gauge("overage_count", "Overage count for the Copilot category.", (q) => q.overageCount);
1655
+ gauge(
1656
+ "overage_entitlement",
1657
+ "Overage entitlement for the Copilot category.",
1658
+ (q) => q.overageEntitlement
1659
+ );
1556
1660
  gauge(
1557
1661
  "percent_remaining",
1558
1662
  "Percent of quota remaining for the Copilot category.",
1559
1663
  (q) => q.percentRemaining
1560
1664
  );
1665
+ booleanGauge(
1666
+ "unlimited",
1667
+ "Whether the Copilot quota category is unlimited.",
1668
+ (q) => q.unlimited
1669
+ );
1670
+ booleanGauge(
1671
+ "overage_permitted",
1672
+ "Whether overage is permitted for the Copilot category.",
1673
+ (q) => q.overagePermitted
1674
+ );
1675
+ booleanGauge("has_quota", "Whether the Copilot quota category has a quota.", (q) => q.hasQuota);
1676
+ booleanGauge(
1677
+ "token_based_billing",
1678
+ "Whether the Copilot quota category uses token-based billing.",
1679
+ (q) => q.tokenBasedBilling
1680
+ );
1681
+ dateGauge(
1682
+ "category_reset_timestamp_seconds",
1683
+ "Unix epoch of the Copilot category-specific quota reset.",
1684
+ (q) => q.quotaResetAt
1685
+ );
1686
+ dateGauge(
1687
+ "category_snapshot_timestamp_seconds",
1688
+ "Unix epoch of the Copilot category quota snapshot.",
1689
+ (q) => q.timestampUtc
1690
+ );
1561
1691
  const resetMs = usage.quotaResetDate ? Date.parse(usage.quotaResetDate) : Number.NaN;
1562
1692
  if (Number.isFinite(resetMs)) {
1563
1693
  lines.push(
@@ -1576,6 +1706,30 @@ var MetricsRegistry = class {
1576
1706
  })} 1`
1577
1707
  );
1578
1708
  }
1709
+ function booleanGauge(suffix, help, pick) {
1710
+ const present = categories.filter(([, quota]) => pick(quota) !== void 0);
1711
+ if (present.length === 0) {
1712
+ return;
1713
+ }
1714
+ lines.push(`# HELP hoopilot_copilot_quota_${suffix} ${help}`);
1715
+ lines.push(`# TYPE hoopilot_copilot_quota_${suffix} gauge`);
1716
+ for (const [category, quota] of present) {
1717
+ lines.push(
1718
+ `hoopilot_copilot_quota_${suffix}${labels({ category })} ${pick(quota) ? 1 : 0}`
1719
+ );
1720
+ }
1721
+ }
1722
+ function dateGauge(suffix, help, pick) {
1723
+ const present = categories.map(([category, quota]) => [category, Date.parse(pick(quota) ?? "")]).filter(([, timestamp]) => Number.isFinite(timestamp));
1724
+ if (present.length === 0) {
1725
+ return;
1726
+ }
1727
+ lines.push(`# HELP hoopilot_copilot_quota_${suffix} ${help}`);
1728
+ lines.push(`# TYPE hoopilot_copilot_quota_${suffix} gauge`);
1729
+ for (const [category, timestamp] of present) {
1730
+ lines.push(`hoopilot_copilot_quota_${suffix}${labels({ category })} ${timestamp / 1e3}`);
1731
+ }
1732
+ }
1579
1733
  }
1580
1734
  };
1581
1735
  function observeResponseUsage(response, fallbackModel, onUsage, signal) {
@@ -1816,6 +1970,12 @@ function createHoopilotHandler(options = {}) {
1816
1970
  "request body was invalid json"
1817
1971
  );
1818
1972
  return finish(jsonError(400, "invalid_request_error", message));
1973
+ } else if (error instanceof OpenAICompatibilityError) {
1974
+ requestLogger.warn(
1975
+ { err: errorDetails(error), event: "http.request.failed" },
1976
+ "request body used unsupported OpenAI compatibility fields"
1977
+ );
1978
+ return finish(jsonError(400, "invalid_request_error", message));
1819
1979
  } else if (error instanceof RequestBodyTooLargeError) {
1820
1980
  requestLogger.warn(
1821
1981
  { err: errorDetails(error), event: "http.request.failed" },
@@ -2267,8 +2427,8 @@ function metricsResponse(metrics) {
2267
2427
  });
2268
2428
  }
2269
2429
  async function handleUsage(metrics, readUsage, signal) {
2270
- const proxy = metrics.snapshot();
2271
2430
  const { copilot, error } = await readUsage(signal);
2431
+ const proxy = metrics.snapshot();
2272
2432
  const body = { copilot: copilot ?? null, object: "usage", proxy };
2273
2433
  if (error) {
2274
2434
  body.copilot_error = error;
@@ -2293,10 +2453,10 @@ function createUsageReader(client, metrics, now = Date.now, ttlMs = USAGE_CACHE_
2293
2453
  metrics.recordCopilotQuota(value);
2294
2454
  return { copilot: value };
2295
2455
  } catch (error) {
2296
- metrics.recordUpstream(usagePath, false);
2297
2456
  if (error instanceof CopilotAuthError) {
2298
2457
  return { error: error.message };
2299
2458
  }
2459
+ metrics.recordUpstream(usagePath, false);
2300
2460
  return { error: errorMessage(error) };
2301
2461
  }
2302
2462
  };