proxitor 0.6.1 → 0.7.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
@@ -256,6 +256,24 @@ provider:
256
256
  curl http://localhost:8828/health
257
257
  ```
258
258
 
259
+ ### Cache usage logging
260
+
261
+ Proxitor automatically logs cache token usage from upstream responses — both non-streaming JSON and streaming SSE. No configuration needed.
262
+
263
+ ```
264
+ [abc123] Cache read: 50000, write: 25000 tokens
265
+ [def456] Cache: no cached tokens
266
+ ```
267
+
268
+ Supports both provider formats:
269
+
270
+ | Provider format | Fields |
271
+ |---|---|
272
+ | Anthropic | `usage.cache_read_input_tokens` / `usage.cache_creation_input_tokens` |
273
+ | OpenAI / OpenRouter | `usage.prompt_tokens_details.cached_tokens` / `cache_write_tokens` |
274
+
275
+ When both formats are present (e.g., OpenRouter relaying an Anthropic response), Anthropic fields take priority.
276
+
259
277
  ---
260
278
 
261
279
  ## Interactive Config Manager
package/dist/cli.mjs CHANGED
@@ -14626,7 +14626,7 @@ function buildRequestHeaders(incoming, config, inject, extraHeaders) {
14626
14626
  const headers = filterHeaders(incoming, STRIP_REQUEST);
14627
14627
  headers.Authorization = formatAuthHeader(config.openrouterKey, config.authType);
14628
14628
  headers["HTTP-Referer"] = config.attributionReferer;
14629
- headers["X-Title"] = config.attributionTitle;
14629
+ headers["X-OpenRouter-Title"] = config.attributionTitle;
14630
14630
  headers["Accept-Encoding"] = "identity";
14631
14631
  if (extraHeaders) Object.assign(headers, extraHeaders);
14632
14632
  if (inject) headers["Content-Type"] = "application/json";
@@ -14640,6 +14640,120 @@ function buildResponseHeaders(from) {
14640
14640
  return headers;
14641
14641
  }
14642
14642
  //#endregion
14643
+ //#region src/proxy/cache-logging.ts
14644
+ function extractCacheUsage(bodyText) {
14645
+ try {
14646
+ const parsed = JSON.parse(bodyText);
14647
+ if (typeof parsed !== "object" || parsed === null) return void 0;
14648
+ const usage = parsed.usage;
14649
+ if (typeof usage !== "object" || usage === null) return void 0;
14650
+ const result = {
14651
+ cacheRead: 0,
14652
+ cacheCreate: 0
14653
+ };
14654
+ if (typeof usage.cache_read_input_tokens === "number") result.cacheRead = usage.cache_read_input_tokens;
14655
+ if (typeof usage.cache_creation_input_tokens === "number") result.cacheCreate = usage.cache_creation_input_tokens;
14656
+ const details = usage.prompt_tokens_details;
14657
+ if (typeof details === "object" && details !== null) {
14658
+ if (typeof details.cached_tokens === "number" && details.cached_tokens > 0 && result.cacheRead === 0) result.cacheRead = details.cached_tokens;
14659
+ if (typeof details.cache_write_tokens === "number" && details.cache_write_tokens > 0 && result.cacheCreate === 0) result.cacheCreate = details.cache_write_tokens;
14660
+ }
14661
+ return result;
14662
+ } catch {
14663
+ return;
14664
+ }
14665
+ }
14666
+ function applyAnthropicFields(u, result) {
14667
+ let found = false;
14668
+ if (typeof u.cache_read_input_tokens === "number" && u.cache_read_input_tokens > 0) {
14669
+ result.cacheRead = u.cache_read_input_tokens;
14670
+ found = true;
14671
+ }
14672
+ if (typeof u.cache_creation_input_tokens === "number" && u.cache_creation_input_tokens > 0) {
14673
+ result.cacheCreate = u.cache_creation_input_tokens;
14674
+ found = true;
14675
+ }
14676
+ return found;
14677
+ }
14678
+ function applyOpenAIFields(details, result) {
14679
+ let found = false;
14680
+ if (typeof details.cached_tokens === "number" && details.cached_tokens > 0) {
14681
+ result.cacheRead = details.cached_tokens;
14682
+ found = true;
14683
+ }
14684
+ if (typeof details.cache_write_tokens === "number" && details.cache_write_tokens > 0) {
14685
+ result.cacheCreate = details.cache_write_tokens;
14686
+ found = true;
14687
+ }
14688
+ return found;
14689
+ }
14690
+ function extractFromEvent(parsed, result) {
14691
+ if (typeof parsed !== "object" || parsed === null) return false;
14692
+ const usage = (parsed.message ?? parsed).usage;
14693
+ if (typeof usage !== "object" || usage === null) return false;
14694
+ const u = usage;
14695
+ let found = false;
14696
+ found = applyAnthropicFields(u, result) || found;
14697
+ const details = u.prompt_tokens_details;
14698
+ if (typeof details === "object" && details !== null) found = applyOpenAIFields(details, result) || found;
14699
+ return found;
14700
+ }
14701
+ function extractCacheUsageFromSSE(fullText) {
14702
+ const result = {
14703
+ cacheRead: 0,
14704
+ cacheCreate: 0
14705
+ };
14706
+ let found = false;
14707
+ for (const line of fullText.split("\n")) {
14708
+ if (!line.startsWith("data:")) continue;
14709
+ const payload = line.slice(5).trim();
14710
+ if (payload === "[DONE]") continue;
14711
+ try {
14712
+ if (extractFromEvent(JSON.parse(payload), result)) found = true;
14713
+ } catch {}
14714
+ }
14715
+ return found ? result : void 0;
14716
+ }
14717
+ function formatCacheUsage(usage, reqId) {
14718
+ const parts = [];
14719
+ if (usage.cacheRead > 0) parts.push(`read: ${usage.cacheRead}`);
14720
+ if (usage.cacheCreate > 0) parts.push(`write: ${usage.cacheCreate}`);
14721
+ logger.info(withReq(reqId, parts.length > 0 ? `Cache ${parts.join(", ")} tokens` : "Cache: no cached tokens"));
14722
+ }
14723
+ function createLoggingStream(contentType, reqId) {
14724
+ const chunks = [];
14725
+ return new TransformStream({
14726
+ transform(chunk, controller) {
14727
+ controller.enqueue(chunk);
14728
+ chunks.push(chunk);
14729
+ },
14730
+ flush() {
14731
+ try {
14732
+ const decoder = new TextDecoder();
14733
+ const text = chunks.reduce((acc, chunk) => acc + decoder.decode(chunk, { stream: true }), "") + decoder.decode();
14734
+ const usage = contentType.toLowerCase().includes("text/event-stream") ? extractCacheUsageFromSSE(text) : extractCacheUsage(text);
14735
+ if (usage) formatCacheUsage(usage, reqId);
14736
+ } catch (err) {
14737
+ logger.debug(withReq(reqId, `Cache usage extraction failed: ${err instanceof Error ? err.message : err}`));
14738
+ }
14739
+ }
14740
+ });
14741
+ }
14742
+ function buildUpstreamResponseWithLogging(upstream, method, reqId) {
14743
+ const headers = buildResponseHeaders(upstream.headers);
14744
+ if (method === "HEAD" || !upstream.body) return new Response(null, {
14745
+ status: upstream.status,
14746
+ headers
14747
+ });
14748
+ const contentType = upstream.headers.get("content-type") ?? "";
14749
+ const lower = contentType.toLowerCase();
14750
+ const body = lower.includes("application/json") || lower.includes("text/event-stream") ? upstream.body.pipeThrough(createLoggingStream(contentType, reqId)) : upstream.body;
14751
+ return new Response(body, {
14752
+ status: upstream.status,
14753
+ headers
14754
+ });
14755
+ }
14756
+ //#endregion
14643
14757
  //#region src/proxy/inject.ts
14644
14758
  /** Extract the model name from a raw request body. Returns undefined if not parseable or absent. */
14645
14759
  function extractModel(rawBody) {
@@ -14705,18 +14819,6 @@ async function fetchUpstream(url, method, headers, body, signal) {
14705
14819
  duplex: body ? "half" : void 0
14706
14820
  });
14707
14821
  }
14708
- function buildUpstreamResponse(upstream, method) {
14709
- const headers = buildResponseHeaders(upstream.headers);
14710
- if (method === "HEAD" || !upstream.body) return new Response(null, {
14711
- status: upstream.status,
14712
- headers
14713
- });
14714
- return new Response(upstream.body, {
14715
- status: upstream.status,
14716
- headers
14717
- });
14718
- }
14719
- /** Read and process the request body, returning an error response on failure */
14720
14822
  async function readRawBody(request, reqId) {
14721
14823
  try {
14722
14824
  return {
@@ -14735,7 +14837,6 @@ async function readRawBody(request, reqId) {
14735
14837
  };
14736
14838
  }
14737
14839
  }
14738
- /** Resolve per-request config: extract model, resolve overrides, build routing and body */
14739
14840
  function resolveRequest(rawBody, config, method, path, reqId) {
14740
14841
  const modelName = extractModel(rawBody);
14741
14842
  const resolved = resolveModelConfig(config, modelName);
@@ -14768,7 +14869,41 @@ function resolveRequest(rawBody, config, method, path, reqId) {
14768
14869
  headers: resolved.headers
14769
14870
  };
14770
14871
  }
14771
- /** Execute upstream fetch, returning appropriate error responses on failure */
14872
+ /**
14873
+ * Extract a readable error detail from an upstream response body.
14874
+ *
14875
+ * OpenRouter error format:
14876
+ * { error: { code: 400, message: "...", metadata: { raw: "...", provider_name: "..." } } }
14877
+ *
14878
+ * - `error.message` — human-readable summary
14879
+ * - `error.metadata.provider_name` — which provider caused it (null = OpenRouter itself)
14880
+ * - `error.metadata.raw` — the original provider error (most specific cause)
14881
+ */
14882
+ function formatMetadata(meta) {
14883
+ const parts = [];
14884
+ if (meta.provider_name) parts.push(`provider=${meta.provider_name}`);
14885
+ if (meta.raw) {
14886
+ const raw = typeof meta.raw === "string" ? meta.raw : JSON.stringify(meta.raw);
14887
+ parts.push(raw);
14888
+ }
14889
+ return parts;
14890
+ }
14891
+ function extractErrorDetail(bodyText) {
14892
+ try {
14893
+ const parsed = JSON.parse(bodyText);
14894
+ if (typeof parsed !== "object" || parsed === null) return bodyText;
14895
+ const err = parsed.error;
14896
+ if (typeof err === "object" && err !== null && err.message) {
14897
+ const parts = [];
14898
+ if (err.code != null) parts.push(String(err.code));
14899
+ parts.push(String(err.message));
14900
+ if (err.metadata && typeof err.metadata === "object") parts.push(...formatMetadata(err.metadata));
14901
+ return parts.join(" | ");
14902
+ }
14903
+ if (parsed.message) return String(parsed.message);
14904
+ } catch {}
14905
+ return bodyText;
14906
+ }
14772
14907
  async function executeUpstream(upstreamUrl, method, headers, body, signal, path, startedAt, reqId) {
14773
14908
  let upstream;
14774
14909
  try {
@@ -14784,8 +14919,23 @@ async function executeUpstream(upstreamUrl, method, headers, body, signal, path,
14784
14919
  type: "proxy_upstream_error"
14785
14920
  } }, { status: 502 });
14786
14921
  }
14922
+ if (upstream.status >= 400) {
14923
+ const bodyText = await upstream.text();
14924
+ const detail = extractErrorDetail(bodyText);
14925
+ const truncated = detail.length > 300 ? `${detail.slice(0, 300)}…` : detail;
14926
+ (upstream.status >= 500 ? logger.error : logger.warn)(withReq(reqId, `${method} ${path} ← ${upstream.status} (${Date.now() - startedAt}ms): ${truncated}`));
14927
+ const responseHeaders = buildResponseHeaders(upstream.headers);
14928
+ if (method === "HEAD") return new Response(null, {
14929
+ status: upstream.status,
14930
+ headers: responseHeaders
14931
+ });
14932
+ return new Response(bodyText, {
14933
+ status: upstream.status,
14934
+ headers: responseHeaders
14935
+ });
14936
+ }
14787
14937
  logger.info(withReq(reqId, `${method} ${path} ← ${upstream.status} (${Date.now() - startedAt}ms)`));
14788
- return buildUpstreamResponse(upstream, method);
14938
+ return buildUpstreamResponseWithLogging(upstream, method, reqId);
14789
14939
  }
14790
14940
  function createProxyServer(config, onReady) {
14791
14941
  const app = new Hono();
@@ -14822,9 +14972,7 @@ function createProxyServer(config, onReady) {
14822
14972
  hostname: config.host
14823
14973
  }, onReady);
14824
14974
  }
14825
- /** Shutdown deadline: force-close after this many ms */
14826
14975
  const SHUTDOWN_TIMEOUT_MS = 1e4;
14827
- /** Start the proxy with graceful shutdown on SIGTERM/SIGINT */
14828
14976
  function startProxyServer(config, onReady) {
14829
14977
  const server = createProxyServer(config, onReady);
14830
14978
  let shuttingDown = false;
@@ -14848,7 +14996,7 @@ function startProxyServer(config, onReady) {
14848
14996
  }
14849
14997
  //#endregion
14850
14998
  //#region src/version.ts
14851
- const version = "0.6.1";
14999
+ const version = "0.7.0";
14852
15000
  //#endregion
14853
15001
  //#region src/cli.ts
14854
15002
  const argv = process.argv.slice(2);