@punktechnologies/sdk 0.1.2 → 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/dist/index.js CHANGED
@@ -2,8 +2,11 @@
2
2
  var PUNK_CHORUS_MODEL = "punk/chorus";
3
3
  var PUNK_MODEL = PUNK_CHORUS_MODEL;
4
4
  var DEFAULT_BASE_URL = "http://localhost:4100";
5
+ var DEFAULT_APP = "default-app";
5
6
  var MAX_TOOL_CACHE_TTL_SECONDS = 24 * 60 * 60;
6
7
  var MAX_TOOL_CACHE_DIMENSION_CHARS = 120;
8
+ var DEFAULT_WAIT_POLL_INTERVAL_MS = 250;
9
+ var DEFAULT_WAIT_TIMEOUT_MS = 30000;
7
10
  function stringifyToolArguments(value) {
8
11
  if (typeof value === "string")
9
12
  return value;
@@ -467,6 +470,16 @@ function cleanPathId(label, value) {
467
470
  }
468
471
  return trimmed;
469
472
  }
473
+ function readEnv(name) {
474
+ const env = globalThis?.process?.env;
475
+ if (!env || typeof env !== "object")
476
+ return;
477
+ const value = env[name];
478
+ return typeof value === "string" ? value : undefined;
479
+ }
480
+ function optionOrEnv(opts, key, envName) {
481
+ return Object.prototype.hasOwnProperty.call(opts, key) ? opts[key] : readEnv(envName);
482
+ }
470
483
  function normalizeBaseUrl(value) {
471
484
  const trimmed = typeof value === "string" ? value.trim() : "";
472
485
  return (trimmed || DEFAULT_BASE_URL).replace(/\/+$/, "");
@@ -513,6 +526,170 @@ function chatMessageText(message) {
513
526
  }
514
527
  return chatMessageContentToString(message?.content);
515
528
  }
529
+ function finiteNumber(value) {
530
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
531
+ }
532
+ function normalizeOpenAIUsage(raw) {
533
+ if (!isRecord(raw?.usage))
534
+ return;
535
+ const usage = raw.usage;
536
+ const normalized = { raw: raw.usage };
537
+ const inputTokens = finiteNumber(usage.prompt_tokens ?? usage.input_tokens);
538
+ const outputTokens = finiteNumber(usage.completion_tokens ?? usage.output_tokens);
539
+ const totalTokens = finiteNumber(usage.total_tokens);
540
+ if (inputTokens !== undefined)
541
+ normalized.inputTokens = inputTokens;
542
+ if (outputTokens !== undefined)
543
+ normalized.outputTokens = outputTokens;
544
+ if (totalTokens !== undefined)
545
+ normalized.totalTokens = totalTokens;
546
+ if (normalized.totalTokens === undefined && inputTokens !== undefined && outputTokens !== undefined) {
547
+ normalized.totalTokens = inputTokens + outputTokens;
548
+ }
549
+ return normalized;
550
+ }
551
+ function normalizeAnthropicUsage(raw) {
552
+ if (!isRecord(raw?.usage))
553
+ return;
554
+ const usage = raw.usage;
555
+ const normalized = { raw: raw.usage };
556
+ const inputTokens = finiteNumber(usage.input_tokens);
557
+ const outputTokens = finiteNumber(usage.output_tokens);
558
+ if (inputTokens !== undefined)
559
+ normalized.inputTokens = inputTokens;
560
+ if (outputTokens !== undefined)
561
+ normalized.outputTokens = outputTokens;
562
+ if (inputTokens !== undefined && outputTokens !== undefined)
563
+ normalized.totalTokens = inputTokens + outputTokens;
564
+ return normalized;
565
+ }
566
+ function normalizeModel(raw) {
567
+ return typeof raw?.model === "string" && raw.model.length > 0 ? raw.model : undefined;
568
+ }
569
+ function normalizeProvider(raw) {
570
+ for (const key of ["provider", "providerName", "provider_name"]) {
571
+ if (typeof raw?.[key] === "string" && raw[key].length > 0)
572
+ return raw[key];
573
+ }
574
+ return;
575
+ }
576
+ function anthropicTextFromContent(value, method, path) {
577
+ if (typeof value === "string")
578
+ return value;
579
+ if (!Array.isArray(value)) {
580
+ throw malformedApiResponse(method, path, "content must be a string or content block array");
581
+ }
582
+ return value.map((part) => {
583
+ if (typeof part === "string")
584
+ return part;
585
+ if (part?.type === "text" && typeof part.text === "string")
586
+ return part.text;
587
+ return "";
588
+ }).join("");
589
+ }
590
+ function anthropicMessageText(raw, method, path) {
591
+ if (!isRecord(raw))
592
+ throw malformedApiResponse(method, path, "missing response object");
593
+ if (!Object.prototype.hasOwnProperty.call(raw, "content")) {
594
+ throw malformedApiResponse(method, path, "missing content");
595
+ }
596
+ return anthropicTextFromContent(raw.content, method, path);
597
+ }
598
+ function anthropicContentBlocks(raw, method, path) {
599
+ if (!isRecord(raw))
600
+ throw malformedApiResponse(method, path, "missing response object");
601
+ const content = raw.content;
602
+ if (typeof content === "string")
603
+ return [{ type: "text", text: content }];
604
+ if (!Array.isArray(content))
605
+ throw malformedApiResponse(method, path, "content must be a string or content block array");
606
+ return content.filter(isRecord);
607
+ }
608
+ function parseSseFrame(frame) {
609
+ const data = [];
610
+ let event;
611
+ for (const line of frame.split(/\r?\n/)) {
612
+ if (line.length === 0 || line.startsWith(":"))
613
+ continue;
614
+ if (line.startsWith("event:")) {
615
+ event = line.slice("event:".length).trim();
616
+ continue;
617
+ }
618
+ if (line.startsWith("data:")) {
619
+ const value = line.slice("data:".length);
620
+ data.push(value.startsWith(" ") ? value.slice(1) : value);
621
+ }
622
+ }
623
+ if (data.length === 0)
624
+ return null;
625
+ return { event, data: data.join(`
626
+ `) };
627
+ }
628
+ async function* sseFrames(res, method, path) {
629
+ if (!res.body) {
630
+ throw new Error(`Punk API ${method} ${path} returned an empty stream`);
631
+ }
632
+ const reader = res.body.getReader();
633
+ const decoder = new TextDecoder;
634
+ let buffer = "";
635
+ try {
636
+ while (true) {
637
+ const { done, value } = await reader.read();
638
+ buffer += decoder.decode(value, { stream: !done });
639
+ let boundary = buffer.search(/\r?\n\r?\n/);
640
+ while (boundary >= 0) {
641
+ const frameText = buffer.slice(0, boundary);
642
+ const match = buffer.slice(boundary).match(/^\r?\n\r?\n/);
643
+ buffer = buffer.slice(boundary + (match?.[0].length ?? 2));
644
+ const frame = parseSseFrame(frameText);
645
+ if (frame)
646
+ yield frame;
647
+ boundary = buffer.search(/\r?\n\r?\n/);
648
+ }
649
+ if (done)
650
+ break;
651
+ }
652
+ const tail = parseSseFrame(buffer.trim());
653
+ if (tail)
654
+ yield tail;
655
+ } finally {
656
+ reader.releaseLock();
657
+ }
658
+ }
659
+ function parseSseJson(frame, method, path) {
660
+ try {
661
+ return JSON.parse(frame.data);
662
+ } catch {
663
+ throw malformedApiResponse(method, path, "stream data was not JSON");
664
+ }
665
+ }
666
+ function streamDeltaText(value) {
667
+ if (value === undefined || value === null)
668
+ return "";
669
+ if (typeof value === "string")
670
+ return value;
671
+ if (Array.isArray(value)) {
672
+ return value.map((part) => typeof part?.text === "string" ? part.text : "").join("");
673
+ }
674
+ return "";
675
+ }
676
+ function isTerminalRunStatus(status) {
677
+ return status === "completed" || status === "failed" || status === "blocked";
678
+ }
679
+ function checkedDelay(ms, signal) {
680
+ if (signal?.aborted)
681
+ return Promise.reject(new Error("Punk wait aborted"));
682
+ return new Promise((resolve, reject) => {
683
+ const timeout = setTimeout(resolve, ms);
684
+ const onAbort = () => {
685
+ clearTimeout(timeout);
686
+ reject(new Error("Punk wait aborted"));
687
+ };
688
+ if (signal) {
689
+ signal.addEventListener("abort", onAbort, { once: true });
690
+ }
691
+ });
692
+ }
516
693
  var TOOL_LEVEL_4 = new Set([
517
694
  "delete",
518
695
  "remove",
@@ -687,12 +864,107 @@ class Punk {
687
864
  agent;
688
865
  subject;
689
866
  apiKey;
867
+ runStack = [];
690
868
  constructor(opts = {}) {
691
- this.baseUrl = normalizeBaseUrl(opts.baseUrl);
692
- this.apiKey = cleanIdentityValue(opts.apiKey);
693
- this.app = cleanIdentityValue(opts.app) ?? "default-app";
694
- this.agent = cleanIdentityValue(opts.agent);
695
- this.subject = cleanIdentityValue(opts.subject);
869
+ this.baseUrl = normalizeBaseUrl(optionOrEnv(opts, "baseUrl", "PUNK_BASE_URL"));
870
+ this.apiKey = cleanIdentityValue(optionOrEnv(opts, "apiKey", "PUNK_API_KEY"));
871
+ this.app = cleanIdentityValue(optionOrEnv(opts, "app", "PUNK_APP")) ?? DEFAULT_APP;
872
+ this.agent = cleanIdentityValue(optionOrEnv(opts, "agent", "PUNK_AGENT"));
873
+ this.subject = cleanIdentityValue(optionOrEnv(opts, "subject", "PUNK_SUBJECT"));
874
+ }
875
+ gateway = {
876
+ chat: (params) => this.chat(params),
877
+ streamChat: (params) => this.streamChat(params),
878
+ stream: (params) => this.chatStream(params)
879
+ };
880
+ openai = {
881
+ chat: (params) => this.chat(params),
882
+ streamChat: (params) => this.streamChat(params),
883
+ stream: (params) => this.chatStream(params),
884
+ config: (opts) => this.openAIConfig(opts)
885
+ };
886
+ anthropic = {
887
+ messages: (params) => this.messages(params),
888
+ streamMessages: (params) => this.streamMessages(params),
889
+ stream: (params) => this.messagesStream(params),
890
+ config: (opts) => this.anthropicConfig(opts)
891
+ };
892
+ identityHeaders(opts = {}) {
893
+ const includeAuthorization = opts.includeAuthorization !== false;
894
+ const headers = {};
895
+ if (opts.includeContentType === true)
896
+ headers["Content-Type"] = "application/json";
897
+ if (includeAuthorization && this.apiKey)
898
+ headers["Authorization"] = `Bearer ${this.apiKey}`;
899
+ const app = cleanIdentityValue(opts.app) ?? this.app;
900
+ const agent = cleanIdentityValue(opts.agent) ?? this.agent;
901
+ const subject = cleanIdentityValue(opts.subject) ?? this.subject;
902
+ headers["X-Punk-App"] = app;
903
+ if (agent)
904
+ headers["X-Punk-Agent"] = agent;
905
+ if (subject)
906
+ headers["X-Punk-Subject"] = subject;
907
+ if (opts.headers) {
908
+ for (const [key, value] of Object.entries(opts.headers)) {
909
+ if (typeof value === "string" && value.trim().length > 0)
910
+ headers[key] = value;
911
+ }
912
+ }
913
+ return headers;
914
+ }
915
+ openAIConfig(opts = {}) {
916
+ const baseUrl = normalizeBaseUrl(opts.baseUrl ?? this.baseUrl);
917
+ const apiKey = cleanIdentityValue(opts.apiKey) ?? this.apiKey ?? "punk-local";
918
+ return {
919
+ baseURL: `${baseUrl}/v1`,
920
+ apiKey,
921
+ defaultHeaders: this.identityHeaders({ ...opts, includeAuthorization: false })
922
+ };
923
+ }
924
+ anthropicConfig(opts = {}) {
925
+ const baseUrl = normalizeBaseUrl(opts.baseUrl ?? this.baseUrl);
926
+ const authToken = cleanIdentityValue(opts.apiKey) ?? this.apiKey ?? "punk-local";
927
+ return {
928
+ baseURL: baseUrl,
929
+ authToken,
930
+ defaultHeaders: this.identityHeaders({ ...opts, includeAuthorization: true })
931
+ };
932
+ }
933
+ vercelAIConfig(opts = {}) {
934
+ return this.vercelOpenAICompatibleConfig(opts);
935
+ }
936
+ vercelOpenAICompatibleConfig(opts = {}) {
937
+ const baseUrl = normalizeBaseUrl(opts.baseUrl ?? this.baseUrl);
938
+ return {
939
+ name: cleanIdentityValue(opts.name) ?? "punk",
940
+ baseURL: `${baseUrl}/v1`,
941
+ apiKey: cleanIdentityValue(opts.apiKey) ?? this.apiKey ?? "punk-local",
942
+ headers: this.identityHeaders({ ...opts, includeAuthorization: false }),
943
+ includeUsage: opts.includeUsage ?? true
944
+ };
945
+ }
946
+ langChainConfig(opts = {}) {
947
+ const baseUrl = normalizeBaseUrl(opts.baseUrl ?? this.baseUrl);
948
+ return {
949
+ apiKey: cleanIdentityValue(opts.apiKey) ?? this.apiKey ?? "punk-local",
950
+ configuration: {
951
+ baseURL: `${baseUrl}/v1`,
952
+ defaultHeaders: this.identityHeaders({ ...opts, includeAuthorization: false })
953
+ }
954
+ };
955
+ }
956
+ currentRunId() {
957
+ return this.runStack.at(-1);
958
+ }
959
+ async withRun(run, fn) {
960
+ const rawRunId = typeof run === "string" ? run : run.runId;
961
+ const id = cleanPathId("run id", rawRunId ?? "");
962
+ this.runStack.push(id);
963
+ try {
964
+ return await fn();
965
+ } finally {
966
+ this.runStack.pop();
967
+ }
696
968
  }
697
969
  async chat(params) {
698
970
  let res;
@@ -719,16 +991,163 @@ class Punk {
719
991
  toolCalls,
720
992
  runId: res.headers.get("x-punk-run-id") ?? "",
721
993
  route: res.headers.get("x-punk-route") ?? "unknown",
722
- raw
994
+ raw,
995
+ usage: normalizeOpenAIUsage(raw),
996
+ model: normalizeModel(raw),
997
+ provider: normalizeProvider(raw)
998
+ };
999
+ }
1000
+ streamChat(params) {
1001
+ return this.chatStream(params);
1002
+ }
1003
+ async* chatStream(params) {
1004
+ const method = "POST";
1005
+ const path = "/v1/chat/completions";
1006
+ let res;
1007
+ try {
1008
+ res = await fetch(`${this.baseUrl}${path}`, {
1009
+ method,
1010
+ headers: this.headers({
1011
+ "X-Punk-App": this.app,
1012
+ "X-Punk-Agent": this.agent,
1013
+ "X-Punk-Subject": this.subject
1014
+ }),
1015
+ body: JSON.stringify({ ...params, stream: true })
1016
+ });
1017
+ } catch (err) {
1018
+ throw this.toTransportError(method, path, err);
1019
+ }
1020
+ if (!res.ok)
1021
+ throw this.toError(method, path, res, await res.text());
1022
+ const runId = res.headers.get("x-punk-run-id") ?? "";
1023
+ const route = res.headers.get("x-punk-route") ?? "unknown";
1024
+ for await (const frame of sseFrames(res, method, path)) {
1025
+ if (frame.data === "[DONE]") {
1026
+ yield { type: "done", content: "", toolCalls: [], runId, route };
1027
+ return;
1028
+ }
1029
+ const raw = parseSseJson(frame, method, path);
1030
+ const choice = Array.isArray(raw?.choices) ? raw.choices[0] : undefined;
1031
+ const delta = isRecord(choice?.delta) ? choice.delta : {};
1032
+ yield {
1033
+ type: "delta",
1034
+ content: streamDeltaText(delta.content),
1035
+ toolCalls: normalizeChatToolCalls(delta.tool_calls),
1036
+ runId,
1037
+ route,
1038
+ raw,
1039
+ usage: normalizeOpenAIUsage(raw),
1040
+ model: normalizeModel(raw),
1041
+ provider: normalizeProvider(raw)
1042
+ };
1043
+ }
1044
+ yield { type: "done", content: "", toolCalls: [], runId, route };
1045
+ }
1046
+ async messages(params) {
1047
+ const method = "POST";
1048
+ const path = "/v1/messages";
1049
+ let res;
1050
+ try {
1051
+ res = await fetch(`${this.baseUrl}${path}`, {
1052
+ method,
1053
+ headers: this.headers({
1054
+ "anthropic-version": "2023-06-01",
1055
+ "X-Punk-App": this.app,
1056
+ "X-Punk-Agent": this.agent,
1057
+ "X-Punk-Subject": this.subject
1058
+ }),
1059
+ body: JSON.stringify({ ...params, stream: false })
1060
+ });
1061
+ } catch (err) {
1062
+ throw this.toTransportError(method, path, err);
1063
+ }
1064
+ const raw = await this.parseJsonResponse(method, path, res);
1065
+ return {
1066
+ content: anthropicMessageText(raw, method, path),
1067
+ contentBlocks: anthropicContentBlocks(raw, method, path),
1068
+ runId: res.headers.get("x-punk-run-id") ?? "",
1069
+ route: res.headers.get("x-punk-route") ?? "unknown",
1070
+ raw,
1071
+ usage: normalizeAnthropicUsage(raw),
1072
+ model: normalizeModel(raw),
1073
+ provider: normalizeProvider(raw),
1074
+ stopReason: typeof raw?.stop_reason === "string" ? raw.stop_reason : undefined
723
1075
  };
724
1076
  }
1077
+ streamMessages(params) {
1078
+ return this.messagesStream(params);
1079
+ }
1080
+ async* messagesStream(params) {
1081
+ const method = "POST";
1082
+ const path = "/v1/messages";
1083
+ let res;
1084
+ try {
1085
+ res = await fetch(`${this.baseUrl}${path}`, {
1086
+ method,
1087
+ headers: this.headers({
1088
+ "anthropic-version": "2023-06-01",
1089
+ "X-Punk-App": this.app,
1090
+ "X-Punk-Agent": this.agent,
1091
+ "X-Punk-Subject": this.subject
1092
+ }),
1093
+ body: JSON.stringify({ ...params, stream: true })
1094
+ });
1095
+ } catch (err) {
1096
+ throw this.toTransportError(method, path, err);
1097
+ }
1098
+ if (!res.ok)
1099
+ throw this.toError(method, path, res, await res.text());
1100
+ const runId = res.headers.get("x-punk-run-id") ?? "";
1101
+ const route = res.headers.get("x-punk-route") ?? "unknown";
1102
+ let model;
1103
+ let usage;
1104
+ for await (const frame of sseFrames(res, method, path)) {
1105
+ const raw = parseSseJson(frame, method, path);
1106
+ if (typeof raw?.message?.model === "string")
1107
+ model = raw.message.model;
1108
+ const messageUsage = normalizeAnthropicUsage(raw.message);
1109
+ if (messageUsage)
1110
+ usage = messageUsage;
1111
+ if (isRecord(raw?.usage)) {
1112
+ usage = normalizeAnthropicUsage({ usage: { ...usage?.raw, ...raw.usage } });
1113
+ }
1114
+ if (raw?.type === "content_block_delta") {
1115
+ yield {
1116
+ type: "delta",
1117
+ content: typeof raw?.delta?.text === "string" ? raw.delta.text : "",
1118
+ runId,
1119
+ route,
1120
+ event: frame.event,
1121
+ raw,
1122
+ usage,
1123
+ model,
1124
+ provider: normalizeProvider(raw)
1125
+ };
1126
+ }
1127
+ if (raw?.type === "message_stop") {
1128
+ yield {
1129
+ type: "done",
1130
+ content: "",
1131
+ runId,
1132
+ route,
1133
+ event: frame.event,
1134
+ raw,
1135
+ usage,
1136
+ model,
1137
+ provider: normalizeProvider(raw)
1138
+ };
1139
+ return;
1140
+ }
1141
+ }
1142
+ yield { type: "done", content: "", runId, route, usage, model };
1143
+ }
725
1144
  traceTool(def) {
726
1145
  const toolName = requireCleanToolName(def.name);
727
1146
  const level = classifySdkToolSideEffect(toolName, def.sideEffectLevel);
728
1147
  const ttlSeconds = normalizeTtlSeconds(def.ttlSeconds);
729
1148
  const schemaFp = cleanOptionalCacheDimension("schemaFp", def.schemaFp);
730
1149
  return async (args, ctx) => {
731
- const runId = cleanIdentityValue(ctx?.runId);
1150
+ const runId = cleanIdentityValue(ctx?.runId) ?? this.currentRunId();
732
1151
  const cacheable = level <= 1 && ttlSeconds !== undefined && runId !== undefined && this.subject !== undefined;
733
1152
  if (runId) {
734
1153
  const decision = await this.tryTraceDecision(runId, "tool.called", {
@@ -906,6 +1325,51 @@ class Punk {
906
1325
  const path = `/api/v1/runs/${encodeURIComponent(cleanPathId("run id", id))}`;
907
1326
  return requireRunDetailResult(await this.request("GET", path), "GET", path);
908
1327
  }
1328
+ async explain(runId) {
1329
+ return this.runDetail(runId).then((detail) => detail.run.routeExplanation ?? null);
1330
+ }
1331
+ async savingsForRun(runId) {
1332
+ const detail = await this.runDetail(runId);
1333
+ return {
1334
+ runId: detail.run.id,
1335
+ route: detail.run.route,
1336
+ status: detail.run.status,
1337
+ costUsd: detail.run.costUsd,
1338
+ savedUsd: detail.run.savedUsd,
1339
+ ghostSavedUsd: detail.run.ghostSavedUsd ?? 0,
1340
+ latencyMs: detail.run.latencyMs,
1341
+ inputTokens: detail.run.inputTokens,
1342
+ outputTokens: detail.run.outputTokens
1343
+ };
1344
+ }
1345
+ async sideEffectsForRun(runId) {
1346
+ return this.runDetail(runId).then((detail) => detail.sideEffects);
1347
+ }
1348
+ async waitForRun(runId, opts = {}) {
1349
+ for await (const detail of this.watchRun(runId, opts)) {
1350
+ if (isTerminalRunStatus(detail.run.status))
1351
+ return detail;
1352
+ }
1353
+ throw new Error(`Punk waitForRun(${cleanPathId("run id", runId)}) ended before the run reached a terminal status`);
1354
+ }
1355
+ async* watchRun(runId, opts = {}) {
1356
+ const id = cleanPathId("run id", runId);
1357
+ const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_WAIT_POLL_INTERVAL_MS;
1358
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
1359
+ const startedAt = Date.now();
1360
+ while (true) {
1361
+ if (opts.signal?.aborted)
1362
+ throw new Error("Punk watchRun aborted");
1363
+ if (Date.now() - startedAt > timeoutMs) {
1364
+ throw new Error(`Punk watchRun(${id}) timed out after ${timeoutMs}ms`);
1365
+ }
1366
+ const detail = await this.runDetail(id);
1367
+ yield detail;
1368
+ if (isTerminalRunStatus(detail.run.status))
1369
+ return;
1370
+ await checkedDelay(pollIntervalMs, opts.signal);
1371
+ }
1372
+ }
909
1373
  async receipt(id) {
910
1374
  const path = `/api/v1/receipts/${encodeURIComponent(cleanPathId("receipt id", id))}`;
911
1375
  return requireReceiptResult(await this.request("GET", path), "GET", path);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@punktechnologies/sdk",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Gateway Agnostic AI runtime SDK for OpenAI, Anthropic, OpenRouter, and more, with agent tracing, tool caching, governance, observability, and cost optimization.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",