@spendgraph/prompt 0.6.0 → 0.8.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
@@ -29,6 +29,9 @@ const reply = await prompt.call({ audience: "students" }, async ({ messages }) =
29
29
  success and failure paths, so the two cannot disagree.
30
30
  - **A missing field throws before anything is sent**, not after you paid for the
31
31
  call.
32
+ - **`packContext` fits competing sources into one window** — instructions and
33
+ the question kept whole, retrieval held to its own share, and whatever is left
34
+ going to the history.
32
35
 
33
36
  ## Docs
34
37
 
@@ -1 +1 @@
1
- class s extends Error{spentMicros;limitMicros;constructor(e,r){super(`Cost ceiling reached: spent $${(e/1e6).toFixed(4)} of $${(r/1e6).toFixed(4)}. Raise maxCostMicros or start a new client.`),this.name="BudgetExceededError",this.spentMicros=e,this.limitMicros=r}}export{s as BudgetExceededError};
1
+ import{formatMicros as t}from"@spendgraph/sdk";class i extends Error{spentMicros;limitMicros;constructor(r,e){super(`Cost ceiling reached: spent ${t(r,4)} of ${t(e,4)}. Raise maxCostMicros or start a new client.`),this.name="BudgetExceededError",this.spentMicros=r,this.limitMicros=e}}export{i as BudgetExceededError};
@@ -1 +1 @@
1
- import{makePrompt as l,noRecorder as d}from"../prompt/index.js";import{applyVariables as r}from"../render/index.js";let u=!1;function m(o){u||(o.inputTokens??0)>0||(o.outputTokens??0)>0||(u=!0,console.warn("[prompt] call() recorded a call with no tokens. This package never sees the provider response, so return inputTokens and outputTokens from your callback or every cost on this prompt reads as zero."))}function c(o,t,n){const a=n.onEmptyTokens??m,s=i=>{try{n.onReportError?.(i)}catch{}};return{enabled:!0,write(i,e){e.status!=="failed"&&(a(e),o.ingest.send([{eventId:e.rolloutId,model:e.model,inputTokens:e.inputTokens??0,outputTokens:e.outputTokens??0,cacheReadTokens:e.cacheReadTokens,cacheWriteTokens:e.cacheWriteTokens,citationTokens:e.citationTokens,reasoningTokens:e.reasoningTokens,metadata:{...n.metadata,prompt:t}}]).catch(s))}}}function T(o,t={}){const n=o.variables??{},a=(o.blocks??[]).map(s=>({title:r(s.title,n),body:r(s.body,n)}));return l({id:o.name,name:o.name,slug:null,blocks:a,question:r(o.question??"",n),fields:o.fields??[],versionId:null,models:o.models??[]},"custom",t.sdk?c(t.sdk,o.name,t):d)}export{T as buildCustomPrompt};
1
+ import{makePrompt as l,noRecorder as c}from"../prompt/index.js";import{applyVariables as i}from"../render/index.js";let u=!1;function k(o){return(o.inputTokens??0)===0&&(o.outputTokens??0)===0}function m(){u||(u=!0,console.warn("[prompt] call() recorded a call with no tokens. This package never sees the provider response, so return inputTokens and outputTokens from your callback or every cost on this prompt reads as zero."))}function p(o,t,n){const s=n.onEmptyTokens??m,a=r=>{try{n.onReportError?.(r)}catch{}};return{enabled:!0,write(r,e){if(e.status!=="failed")return k(e)&&s(e),o.ingest.priced({eventId:e.rolloutId,model:e.model,inputTokens:e.inputTokens??0,outputTokens:e.outputTokens??0,cacheReadTokens:e.cacheReadTokens,cacheWriteTokens:e.cacheWriteTokens,citationTokens:e.citationTokens,reasoningTokens:e.reasoningTokens,metadata:{...n.metadata,prompt:t}}).catch(d=>{a(d)})}}}function b(o,t={}){const n=o.variables??{},s=(o.blocks??[]).map(r=>({title:i(r.title,n),body:i(r.body,n)})),a=i(o.question??"",n);return l({id:o.name,name:o.name,slug:null,blocks:s,question:a,fields:o.fields??[],versionId:null,models:o.models??[]},"custom",t.sdk?p(t.sdk,o.name,t):c)}export{b as buildCustomPrompt};
@@ -12,6 +12,22 @@ export interface CacheOptions<T = unknown> {
12
12
  maxSize?: number;
13
13
  /** Injectable clock, so tests do not sleep. */
14
14
  now?: () => number;
15
+ /**
16
+ * Spread this fraction of the TTL across instances. Default 0.
17
+ *
18
+ * A fleet deployed together pulls together, so every copy's entry goes stale
19
+ * in the same second and they all refetch at once — and again a TTL later,
20
+ * forever. The server sees a wave rather than a trickle, and the wave gets
21
+ * taller as the fleet grows.
22
+ *
23
+ * Drawn once per cache rather than per entry: one process should not have its
24
+ * prompts expiring at a dozen different moments, and what needs to differ is
25
+ * one instance from the next. `0.1` keeps the TTL within a tenth of what was
26
+ * asked for, which is far less than the staleness a TTL already allows.
27
+ */
28
+ jitter?: number;
29
+ /** Injectable randomness, so the spread above is reproducible in a test. */
30
+ random?: () => number;
15
31
  /**
16
32
  * How long to leave a key alone after its refresh failed. Default 60.
17
33
  *
@@ -1 +1 @@
1
- class a{store=new Map;ttlMs;maxSize;now;refreshing=new Set;failures=new Map;retryAfterMs;onRefreshError;identify;tick=0;invalidatedAt=new Map;clearedAt=-1;outstanding=0;constructor(t={}){this.ttlMs=(t.ttlSeconds??300)*1e3,this.maxSize=Math.max(1,t.maxSize??100),this.now=t.now??(()=>Date.now()),this.retryAfterMs=(t.retryAfterSeconds??60)*1e3,this.onRefreshError=t.onRefreshError,this.identify=t.identify}get(t){const s=this.store.get(t);if(!s)return;this.store.delete(t),this.store.set(t,s);const e=this.now()-s.at;return{value:s.value,stale:e>this.ttlMs,ageMs:e}}set(t,s){for(this.store.delete(t),this.store.set(t,{value:s,at:this.now()});this.store.size>this.maxSize;){const e=this.store.keys().next().value;if(e===void 0)break;this.store.delete(e),this.refreshing.has(e)||this.failures.delete(e)}}refreshInBackground(t,s){if(this.refreshing.has(t))return;const e=this.failures.get(t);if(e!==void 0&&this.now()-e<this.retryAfterMs)return;this.refreshing.add(t);const i=this.beginFetch();s().then(r=>this.settle(t,r,i)).catch(r=>{this.abandon(t);try{this.onRefreshError?.(r,t)}catch{}}).finally(()=>{this.refreshing.delete(t),this.forgetLogIfIdle()})}beginFetch(){return this.outstanding++,this.tick}settle(t,s,e){this.outstanding--;const i=[t,...this.identify?.(s)??[]];e<this.clearedAt||i.some(n=>(this.invalidatedAt.get(n)??-1)>e)||(this.failures.delete(t),this.set(t,s)),this.forgetLogIfIdle()}abandon(t){this.outstanding--,this.failures.set(t,this.now()),this.boundFailures(),this.forgetLogIfIdle()}invalidate(t){this.invalidatedAt.set(t,++this.tick)}forgetLogIfIdle(){this.outstanding===0&&this.refreshing.size===0&&this.invalidatedAt.clear()}boundFailures(){for(;this.failures.size>this.maxSize;){const t=this.failures.keys().next().value;if(t===void 0)break;this.failures.delete(t)}}keysWhere(t){const s=[];for(const[e,i]of this.store)t(i.value)&&s.push(e);return s}delete(t){this.store.delete(t),this.failures.delete(t),this.invalidate(t),this.forgetLogIfIdle()}clear(){this.clearedAt=++this.tick,this.store.clear(),this.failures.clear(),this.forgetLogIfIdle()}get size(){return this.store.size}get trackedKeys(){return new Set([...this.invalidatedAt.keys(),...this.failures.keys()]).size}}export{a as PullCache};
1
+ function a(r,e=0,s=Math.random){if(r<=0||e<=0)return r;const t=Math.min(.99,e);return Math.round(r*(1+(s()*2-1)*t))}class o{store=new Map;ttlMs;maxSize;now;refreshing=new Set;failures=new Map;retryAfterMs;onRefreshError;identify;tick=0;invalidatedAt=new Map;clearedAt=-1;outstanding=0;constructor(e={}){this.ttlMs=a((e.ttlSeconds??300)*1e3,e.jitter,e.random),this.maxSize=Math.max(1,e.maxSize??100),this.now=e.now??(()=>Date.now()),this.retryAfterMs=(e.retryAfterSeconds??60)*1e3,this.onRefreshError=e.onRefreshError,this.identify=e.identify}get(e){const s=this.store.get(e);if(!s)return;this.store.delete(e),this.store.set(e,s);const t=this.now()-s.at;return{value:s.value,stale:t>this.ttlMs,ageMs:t}}set(e,s){for(this.store.delete(e),this.store.set(e,{value:s,at:this.now()});this.store.size>this.maxSize;){const t=this.store.keys().next().value;if(t===void 0)break;this.store.delete(t),this.refreshing.has(t)||this.failures.delete(t)}}refreshInBackground(e,s){if(this.refreshing.has(e))return;const t=this.failures.get(e);if(t!==void 0&&this.now()-t<this.retryAfterMs)return;this.refreshing.add(e);const i=this.beginFetch();s().then(n=>this.settle(e,n,i)).catch(n=>{this.abandon(e);try{this.onRefreshError?.(n,e)}catch{}}).finally(()=>{this.refreshing.delete(e),this.forgetLogIfIdle()})}beginFetch(){return this.outstanding++,this.tick}settle(e,s,t){this.outstanding--;const i=[e,...this.identify?.(s)??[]];t<this.clearedAt||i.some(h=>(this.invalidatedAt.get(h)??-1)>t)||(this.failures.delete(e),this.set(e,s)),this.forgetLogIfIdle()}abandon(e){this.outstanding--,this.failures.set(e,this.now()),this.boundFailures(),this.forgetLogIfIdle()}invalidate(e){this.invalidatedAt.set(e,++this.tick)}forgetLogIfIdle(){this.outstanding===0&&this.refreshing.size===0&&this.invalidatedAt.clear()}boundFailures(){for(;this.failures.size>this.maxSize;){const e=this.failures.keys().next().value;if(e===void 0)break;this.failures.delete(e)}}keysWhere(e){const s=[];for(const[t,i]of this.store)e(i.value)&&s.push(t);return s}delete(e){this.store.delete(e),this.failures.delete(e),this.invalidate(e),this.forgetLogIfIdle()}clear(){this.clearedAt=++this.tick,this.store.clear(),this.failures.clear(),this.forgetLogIfIdle()}get size(){return this.store.size}get trackedKeys(){return new Set([...this.invalidatedAt.keys(),...this.failures.keys()]).size}}export{o as PullCache};
package/dist/client.d.ts CHANGED
@@ -50,6 +50,19 @@ export declare class PromptClient {
50
50
  * you passed, so pulling the same prompt both ways keeps two entries.
51
51
  */
52
52
  pull(handle: string): Promise<Prompt>;
53
+ /**
54
+ * Registers a prompt written in code, and hands back the stored one.
55
+ *
56
+ * The wording goes up as it stands, so what runs afterwards is the text that
57
+ * ran before. What comes back is a server prompt: it has an id, a version,
58
+ * and its calls write rollouts where the built one wrote usage.
59
+ *
60
+ * `fields` are not sent. The app derives them from the `{placeholders}` in
61
+ * the blocks and the question, so a spec whose fields say more than its
62
+ * placeholders do — a default, a range, an enum — loses that here and is
63
+ * worth setting on the prompt afterwards.
64
+ */
65
+ create(spec: PromptSpec, projectId: string): Promise<Prompt>;
53
66
  /** A prompt written in code, wired to record its usage through this client. */
54
67
  build(spec: PromptSpec, opts?: Omit<BuildOptions, "sdk">): Prompt;
55
68
  /**
@@ -107,6 +120,13 @@ export declare class PromptClient {
107
120
  * Invalidates the cache: a promotion that left a warm entry in place would
108
121
  * keep sending the old wording for up to a TTL, which is the one moment a
109
122
  * caller is actively watching for the change.
123
+ *
124
+ * **This client's cache.** Every other process holding the same prompt keeps
125
+ * serving what it already pulled until its own TTL lapses, which for an edit
126
+ * is what "within a TTL" means and for a rollback is up to five minutes of
127
+ * continued harm — a version is usually rolled back because it is doing some.
128
+ * A fleet that needs the change at once has each instance call `invalidate`
129
+ * when it hears about the promotion, or pull with `cache: { ttlSeconds: 0 }`.
110
130
  */
111
131
  promote(promptId: string, versionId: string): Promise<{
112
132
  promoted: string;
package/dist/client.js CHANGED
@@ -1 +1 @@
1
- import{Spendgraph as h}from"@spendgraph/sdk";import{Budget as d}from"./budget/index.js";import{buildCustomPrompt as l}from"./build/index.js";import{PullCache as m}from"./cache/index.js";import{makePrompt as f}from"./prompt/index.js";import{pullWithCache as y}from"./pull/index.js";import{newRolloutId as i}from"./run/id.js";import{runAcrossModels as k,runOnce as w,sampleRuns as g}from"./run/index.js";const b=4;function E(o,r){const e=o?.prompt;if(!e||typeof e!="object"||typeof e.id!="string")throw new Error(`Asked for prompt "${r}" and the response carried no prompt. Check the base URL points at a spendgraph app and that the key can read this project.`);return e}class R{sdk;cache;hasKey;onReportError;concurrency;budget;warnedAboutTokens=!1;constructor(r){const{cache:e,onReportError:t,onPullError:s,concurrency:a,maxCostMicros:c,...u}=r;this.sdk=new h(u),this.cache=new m({...e,onRefreshError:e?.onRefreshError??s,identify:n=>[n.id,n.slug].filter(p=>!!p)}),this.hasKey=!!r.apiKey,this.onReportError=t,this.concurrency=Math.max(1,a??b),this.budget=new d("maxCostMicros"in r?c:void 0)}recorder(){return{enabled:!0,write:(r,e)=>(this.warnOnEmptyTokens(e),this.priced(r,e))}}async pull(r){const e=await y(this.cache,r,async()=>{const t=await this.sdk.prompts.get(r);return E(t,r)});return f({id:e.id,name:e.name??e.id,slug:e.slug??null,blocks:e.blocks??[],question:e.question??"",fields:e.fieldSpec??[],versionId:e.currentVersionId??null,models:e.models??[]},"server",this.recorder())}build(r,e={}){return l(r,{onReportError:this.onReportError,...e,sdk:this.sdk})}warnOnEmptyTokens(r){this.warnedAboutTokens||r.status!=="failed"&&((r.inputTokens??0)>0||(r.outputTokens??0)>0||(this.warnedAboutTokens=!0,console.warn("[prompt] call() recorded a rollout with no tokens. This package never sees the provider response, so return inputTokens and outputTokens from your callback or every cost on this prompt reads as zero.")))}async run(r,e={},t={}){return w(this.sdk.prompts,r,e,t,this.budget)}async sample(r,e={},t={}){return g(this.sdk.prompts,r,e,{concurrency:this.concurrency,...t},this.budget)}async runAll(r,e={},t={}){const s=t.models??(await this.pull(r)).models;if(s.length===0)throw new Error("No models given and the prompt has none saved.");return k(this.sdk.prompts,r,e,s,{concurrency:this.concurrency,...t},this.budget)}async cases(r){return this.sdk.prompts.cases(r)}async setCases(r,e){return this.sdk.prompts.putCases(r,e)}spent(){return this.budget.spent()}remaining(){return this.budget.remaining()}async versions(r,e={}){return this.sdk.prompts.versions(r,e)}async promote(r,e){const t=await this.sdk.prompts.promote(r,e);return this.invalidate(r),t}invalidate(r){if(!r){this.cache.clear();return}for(const e of this.cache.keysWhere(t=>t.id===r||t.slug===r))this.cache.delete(e);this.cache.delete(r),this.cache.invalidate(r)}async priced(r,e){const t=e.rolloutId??i();if(this.hasKey)try{const s=await this.sdk.prompts.report(r,{...e,rolloutId:t});return s.priced===!1?void 0:s.rollout?.costMicros}catch(s){this.onReportError?.(s);return}}async report(r,e){const t=e.rolloutId??i();if(!this.hasKey)return t;try{await this.sdk.prompts.report(r,{...e,rolloutId:t})}catch(s){this.onReportError?.(s)}return t}}function O(o,r){return new R(r).pull(o)}export{R as PromptClient,O as pullPrompt};
1
+ import{Spendgraph as h}from"@spendgraph/sdk";import{Budget as p}from"./budget/index.js";import{buildCustomPrompt as m}from"./build/index.js";import{PullCache as f}from"./cache/index.js";import{makePrompt as k}from"./prompt/index.js";import{pullWithCache as y}from"./pull/index.js";import{newRolloutId as i}from"./run/id.js";import{runAcrossModels as b,runOnce as w,sampleRuns as g}from"./run/index.js";const E=4;function a(o,r){const e=o?.prompt;if(!e||typeof e!="object"||typeof e.id!="string")throw new Error(`Asked for prompt "${r}" and the response carried no prompt. Check the base URL points at a spendgraph app and that the key can read this project.`);return e}class v{sdk;cache;hasKey;onReportError;concurrency;budget;warnedAboutTokens=!1;constructor(r){const{cache:e,onReportError:t,onPullError:s,concurrency:c,maxCostMicros:u,...d}=r;this.sdk=new h(d),this.cache=new f({...e,onRefreshError:e?.onRefreshError??s,identify:n=>[n.id,n.slug].filter(l=>!!l)}),this.hasKey=!!r.apiKey,this.onReportError=t,this.concurrency=Math.max(1,c??E),this.budget=new p("maxCostMicros"in r?u:void 0)}recorder(){return{enabled:!0,write:(r,e)=>(this.warnOnEmptyTokens(e),this.priced(r,e))}}async pull(r){const e=await y(this.cache,r,async()=>{const t=await this.sdk.prompts.get(r);return a(t,r)});return k({id:e.id,name:e.name??e.id,slug:e.slug??null,blocks:e.blocks??[],question:e.question??"",fields:e.fieldSpec??[],versionId:e.currentVersionId??null,models:e.models??[]},"server",this.recorder())}async create(r,e){const t=await this.sdk.prompts.create({projectId:e,name:r.name,...r.blocks?{blocks:r.blocks}:{},...r.question===void 0?{}:{question:r.question},...r.variables?{variables:r.variables}:{},...r.models?{models:r.models}:{}}),s=a(t,r.name);return this.pull(s.id)}build(r,e={}){return m(r,{onReportError:this.onReportError,...e,sdk:this.sdk})}warnOnEmptyTokens(r){this.warnedAboutTokens||r.status!=="failed"&&((r.inputTokens??0)>0||(r.outputTokens??0)>0||(this.warnedAboutTokens=!0,console.warn("[prompt] call() recorded a rollout with no tokens. This package never sees the provider response, so return inputTokens and outputTokens from your callback or every cost on this prompt reads as zero.")))}async run(r,e={},t={}){return w(this.sdk.prompts,r,e,t,this.budget)}async sample(r,e={},t={}){return g(this.sdk.prompts,r,e,{concurrency:this.concurrency,...t},this.budget)}async runAll(r,e={},t={}){const s=t.models??(await this.pull(r)).models;if(s.length===0)throw new Error("No models given and the prompt has none saved.");return b(this.sdk.prompts,r,e,s,{concurrency:this.concurrency,...t},this.budget)}async cases(r){return this.sdk.prompts.cases(r)}async setCases(r,e){return this.sdk.prompts.putCases(r,e)}spent(){return this.budget.spent()}remaining(){return this.budget.remaining()}async versions(r,e={}){return this.sdk.prompts.versions(r,e)}async promote(r,e){const t=await this.sdk.prompts.promote(r,e);return this.invalidate(r),t}invalidate(r){if(!r){this.cache.clear();return}for(const e of this.cache.keysWhere(t=>t.id===r||t.slug===r))this.cache.delete(e);this.cache.delete(r),this.cache.invalidate(r)}async priced(r,e){const t=e.rolloutId??i();if(this.hasKey)try{const s=await this.sdk.prompts.report(r,{...e,rolloutId:t});return s.priced===!1?void 0:s.rollout?.costMicros}catch(s){this.onReportError?.(s);return}}async report(r,e){const t=e.rolloutId??i();if(!this.hasKey)return t;try{await this.sdk.prompts.report(r,{...e,rolloutId:t})}catch(s){this.onReportError?.(s)}return t}}function M(o,r){return new v(r).pull(o)}export{v as PromptClient,M as pullPrompt};
@@ -0,0 +1,23 @@
1
+ /** Everything about a prompt that decides what the model is actually sent. */
2
+ export interface Worded {
3
+ name: string;
4
+ blocks: readonly {
5
+ title: string;
6
+ body: string;
7
+ }[];
8
+ question: string;
9
+ }
10
+ /**
11
+ * A stable id for what a prompt says, so its cost history follows the wording
12
+ * rather than where the wording is kept.
13
+ *
14
+ * Built here or stored on spendgraph, the same text fingerprints the same, so
15
+ * registering a prompt written in code carries its history across instead of
16
+ * starting over under a new version id.
17
+ *
18
+ * Not a cryptographic digest. It is a cache key, and it has to be computed
19
+ * synchronously everywhere this package runs, where `crypto.subtle` is async
20
+ * and `node:crypto` is not there at all. A collision costs two prompts a shared
21
+ * estimate, which is the whole of the damage.
22
+ */
23
+ export declare function fingerprint(worded: Worded): string;
@@ -0,0 +1 @@
1
+ const a=2166136261,e=16777619,s="\0",h="";function c(t,n){let o=n>>>0;for(let i=0;i<t.length;i++)o^=t.charCodeAt(i),o=Math.imul(o,16777619)>>>0;return o>>>0}function r(t){return[t.name,...t.blocks.map(n=>`${n.title}\0${n.body}`),t.question].join("")}function g(t){const n=r(t),o=c(n,2166136261).toString(16).padStart(8,"0");return`sig:${c(n,2166136261^n.length+1).toString(16).padStart(8,"0")}${o}`}export{g as fingerprint};
@@ -0,0 +1,2 @@
1
+ export type { Worded } from "./fingerprint.js";
2
+ export { fingerprint } from "./fingerprint.js";
@@ -0,0 +1 @@
1
+ import{fingerprint as f}from"./fingerprint.js";export{f as fingerprint};
package/dist/index.d.ts CHANGED
@@ -5,7 +5,9 @@ export type { BuildOptions } from "./build/index.js";
5
5
  export { buildCustomPrompt } from "./build/index.js";
6
6
  export type { PromptClientOptions } from "./client.js";
7
7
  export { PromptClient, pullPrompt } from "./client.js";
8
+ export type { ContextLayer, LayerFit, PackedContext, PackOptions } from "./pack/index.js";
9
+ export { approxTokens, packContext } from "./pack/index.js";
8
10
  export type { Answer, BatchOptions, Chain, InvokeOptions, Model, ModelOptions, } from "./prompt/index.js";
9
11
  export { MAX_RECORDED_MESSAGES } from "./prompt/index.js";
10
- export type { Block, History, Message } from "./render/index.js";
12
+ export type { Block, History, Message, MessageCall } from "./render/index.js";
11
13
  export type { DatasetCase, DatasetSummary, Prompt, PromptSource, PromptSpec, PromptVersion, Recordable, RenderOptions, ReportInput, RunOptions, RunResult, Split, TraceHandle, TraceOptions, TraceOutcome, TurnRecord, TurnSource, } from "./types/index.js";
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import{FieldValidationError as t}from"@spendgraph/sdk";import{BudgetExceededError as m}from"./budget/index.js";import{buildCustomPrompt as E}from"./build/index.js";import{PromptClient as l,pullPrompt as x}from"./client.js";import{MAX_RECORDED_MESSAGES as i}from"./prompt/index.js";export{m as BudgetExceededError,t as FieldValidationError,i as MAX_RECORDED_MESSAGES,l as PromptClient,E as buildCustomPrompt,x as pullPrompt};
1
+ import{FieldValidationError as t}from"@spendgraph/sdk";import{BudgetExceededError as p}from"./budget/index.js";import{buildCustomPrompt as x}from"./build/index.js";import{PromptClient as d,pullPrompt as f}from"./client.js";import{approxTokens as i,packContext as a}from"./pack/index.js";import{MAX_RECORDED_MESSAGES as u}from"./prompt/index.js";export{p as BudgetExceededError,t as FieldValidationError,u as MAX_RECORDED_MESSAGES,d as PromptClient,i as approxTokens,x as buildCustomPrompt,a as packContext,f as pullPrompt};
@@ -10,6 +10,8 @@ export { serializeFields, validateFields } from "@spendgraph/sdk";
10
10
  export { Budget } from "./budget/index.js";
11
11
  export type { CacheHit, CacheOptions } from "./cache/index.js";
12
12
  export { PullCache } from "./cache/index.js";
13
+ export type { Worded } from "./fingerprint/index.js";
14
+ export { fingerprint } from "./fingerprint/index.js";
13
15
  export type { PromptShape, Recorder } from "./prompt/index.js";
14
16
  export { bind, makePrompt, noRecorder } from "./prompt/index.js";
15
17
  export { pullWithCache } from "./pull/index.js";
package/dist/internals.js CHANGED
@@ -1 +1 @@
1
- import{serializeFields as r,validateFields as t}from"@spendgraph/sdk";import{Budget as m}from"./budget/index.js";import{PullCache as p}from"./cache/index.js";import{bind as d,makePrompt as f,noRecorder as x}from"./prompt/index.js";import{pullWithCache as c}from"./pull/index.js";import{mapLimit as n,newRolloutId as s}from"./run/index.js";export{m as Budget,p as PullCache,d as bind,f as makePrompt,n as mapLimit,s as newRolloutId,x as noRecorder,c as pullWithCache,r as serializeFields,t as validateFields};
1
+ import{serializeFields as o,validateFields as t}from"@spendgraph/sdk";import{Budget as m}from"./budget/index.js";import{PullCache as l}from"./cache/index.js";import{fingerprint as a}from"./fingerprint/index.js";import{bind as x,makePrompt as n,noRecorder as u}from"./prompt/index.js";import{pullWithCache as h}from"./pull/index.js";import{mapLimit as g,newRolloutId as C}from"./run/index.js";export{m as Budget,l as PullCache,x as bind,a as fingerprint,n as makePrompt,g as mapLimit,C as newRolloutId,u as noRecorder,h as pullWithCache,o as serializeFields,t as validateFields};
@@ -0,0 +1,3 @@
1
+ export type { ContextLayer, LayerFit, PackedContext, PackOptions } from "./pack.js";
2
+ export { packContext } from "./pack.js";
3
+ export { approxTokens } from "./tokens.js";
@@ -0,0 +1 @@
1
+ import{packContext as r}from"./pack.js";import{approxTokens as t}from"./tokens.js";export{t as approxTokens,r as packContext};
@@ -0,0 +1,58 @@
1
+ export interface ContextLayer {
2
+ name: string;
3
+ /** Its parts, the ones most worth keeping first. */
4
+ parts: string[];
5
+ /** Tokens this layer may have. Omit and it shares whatever the others leave. */
6
+ budget?: number;
7
+ /**
8
+ * Kept whole, or the pack says it did not fit.
9
+ *
10
+ * The instructions and the question are the two nothing is worth dropping —
11
+ * a context that trimmed the question to make room for retrieved documents
12
+ * has spent the whole window on the wrong half of the request.
13
+ */
14
+ required?: boolean;
15
+ }
16
+ export interface LayerFit {
17
+ name: string;
18
+ kept: string[];
19
+ /** Parts that did not fit. */
20
+ dropped: number;
21
+ tokens: number;
22
+ /** What this layer was allowed: its own budget, or what was left when its turn came. */
23
+ allowed: number;
24
+ }
25
+ export interface PackedContext {
26
+ layers: LayerFit[];
27
+ /** Kept parts by layer name, ready to fill a template. */
28
+ kept: Record<string, string[]>;
29
+ tokens: number;
30
+ /** Room left under the budget. */
31
+ left: number;
32
+ /** The required layers alone did not fit, so the budget was never real. */
33
+ over: boolean;
34
+ }
35
+ export interface PackOptions {
36
+ /** Tokens the whole context may use. */
37
+ budget: number;
38
+ /** How a part is measured. Defaults to `approxTokens`. */
39
+ count?: (text: string) => number;
40
+ }
41
+ /**
42
+ * Fits several competing sources into one context window.
43
+ *
44
+ * A prompt's sources do not compete evenly. The instructions and the question
45
+ * have to be there whatever else is; retrieval is worth a fixed share and no
46
+ * more, because the tenth-best document is rarely worth the history it
47
+ * displaced; and what is left goes to the layers that can take whatever they
48
+ * are given. Assembling in that order is what stops the longest source — nearly
49
+ * always the history — from quietly crowding out the ones that were chosen.
50
+ *
51
+ * Parts within a layer are kept in the order given, so rank before packing:
52
+ * this drops from the end. A part that does not fit is dropped rather than cut
53
+ * in half, since half a retrieved document reads as a whole one to the model.
54
+ *
55
+ * Required layers are taken whole even when they overflow, and `over` says so.
56
+ * The alternative is a context that silently answers a trimmed question.
57
+ */
58
+ export declare function packContext(layers: ContextLayer[], opts: PackOptions): PackedContext;
@@ -0,0 +1 @@
1
+ import{approxTokens as u}from"./tokens.js";function m(e,o){const a=e.parts.reduce((r,t)=>r+o(t),0);return{name:e.name,kept:[...e.parts],dropped:0,tokens:a,allowed:a}}function d(e,o,a){const r=[];let t=0;for(const p of e.parts){const c=a(p);if(t+c>o)break;r.push(p),t+=c}return{name:e.name,kept:r,dropped:e.parts.length-r.length,tokens:t,allowed:o}}function k(e,o){const a=o.count??u,r=new Map;let t=0;for(const n of e.filter(s=>s.required)){const s=m(n,a);r.set(n.name,s),t+=s.tokens}const p=t>o.budget;for(const n of e.filter(s=>!s.required)){const s=Math.max(0,o.budget-t),f=d(n,Math.min(n.budget??s,s),a);r.set(n.name,f),t+=f.tokens}const c=e.map(n=>r.get(n.name));return{layers:c,kept:Object.fromEntries(c.map(n=>[n.name,n.kept])),tokens:t,left:Math.max(0,o.budget-t),over:p}}export{k as packContext};
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Roughly how many tokens a string is, at four characters each.
3
+ *
4
+ * An estimate, and this package has no tokenizer to make it anything else —
5
+ * every provider's differs, and pulling one in would tie a rendering package to
6
+ * one vendor's vocabulary. Close enough to pack against, and the reason a pack
7
+ * leaves headroom rather than filling the window to its last token.
8
+ *
9
+ * Pass your own counter to `packContext` where the exact number matters.
10
+ */
11
+ export declare function approxTokens(text: string): number;
@@ -0,0 +1 @@
1
+ const o=4;function e(n){return n?Math.ceil(n.length/4):0}export{e as approxTokens};
@@ -1,5 +1,5 @@
1
- import type { History, Message } from "../render/index.js";
2
- import type { Prompt, TraceOutcome } from "../types/index.js";
1
+ import type { Message } from "../render/index.js";
2
+ import type { Prompt, TraceOptions, TraceOutcome } from "../types/index.js";
3
3
  /**
4
4
  * Anything that can answer a list of messages.
5
5
  *
@@ -17,22 +17,8 @@ export interface ModelOptions {
17
17
  tools?: unknown;
18
18
  onText?: (delta: string) => void;
19
19
  }
20
- export interface InvokeOptions {
21
- /** Prior turns, oldest first. */
22
- history?: History;
23
- /** A bus to select from. The turn is passed to the model and to the rollout. */
24
- tools?: {
25
- trace(query?: string, limit?: number): {
26
- record(): unknown;
27
- };
28
- };
29
- /** What to select tools on. Defaults to the rendered question. */
30
- query?: string;
31
- /** False runs the model and records nothing. */
32
- report?: boolean;
33
- /** Supply your own to make a retry safe. */
34
- rolloutId?: string;
35
- }
20
+ /** What one invocation may be given; the same options `Prompt.call` takes. */
21
+ export type InvokeOptions = TraceOptions;
36
22
  export interface BatchOptions extends InvokeOptions {
37
23
  /** Max calls in flight. Default 4. */
38
24
  concurrency?: number;
@@ -49,6 +35,15 @@ export interface Chain {
49
35
  onText?: (delta: string) => void;
50
36
  }): Promise<Answer>;
51
37
  /** One call per set of values, concurrency-capped. */
38
+ /**
39
+ * Every row, at most `concurrency` at a time, in the order they were given.
40
+ *
41
+ * A row that throws comes back as a failed `Answer` rather than taking the
42
+ * batch down with it — the point of sending five hundred is that the other
43
+ * four hundred and ninety-nine still land, and they have already been paid
44
+ * for by the time one of them fails. Each carries the `rolloutId` its record
45
+ * was written under, so a failure is traceable to a row rather than to a job.
46
+ */
52
47
  batch(values: Record<string, unknown>[], opts?: BatchOptions): Promise<Answer[]>;
53
48
  }
54
49
  /**
@@ -1 +1 @@
1
- import{mapLimit as m}from"../run/concurrency.js";const s=4;function f(i,r){const c=(n,t,e)=>i.call(n,({messages:o,turn:l})=>{const a={tools:l,onText:t.onText};return e&&r.stream?r.stream(o,a):r.call(o,a)},t);return{invoke:(n={},t={})=>c(n,t,!1),stream:(n={},t={})=>c(n,t,!0),batch:(n,t={})=>m(n,t.concurrency??s,e=>c(e,t,!1))}}export{f as bind};
1
+ import{mapLimit as i}from"../run/concurrency.js";import{newRolloutId as m}from"../run/id.js";const d=4;function w(c,n){const e=(r,t,o)=>c.call(r,({messages:l,turn:s})=>{const a={tools:s,onText:t.onText};return o&&n.stream?n.stream(l,a):n.call(l,a)},t),u=async(r,t)=>{try{return await e(r,t,!1)}catch(o){return{rolloutId:t.rolloutId,output:"",model:"unknown",status:"failed",error:o instanceof Error?o.message:String(o)}}};return{invoke:(r={},t={})=>e(r,t,!1),stream:(r={},t={})=>e(r,t,!0),batch:(r,t={})=>i(r,t.concurrency??d,o=>u(o,{...t,rolloutId:t.rolloutId??m()}))}}export{w as bind};
@@ -1 +1 @@
1
- import{serializeFields as S}from"@spendgraph/sdk";import{findVariables as v,renderMessages as I}from"../render/index.js";import{newRolloutId as E}from"../run/id.js";import{bind as q}from"./bind.js";const $={enabled:!1,write:()=>{}},A=50;let D=!1;function j(e){D||e.length<=A||(D=!0,console.warn(`[prompt] ${e.length} messages is over the ${A} a rollout can record. The call still runs; the rollout will be rejected. Trim the history you pass.`))}function x(e,r,l){const s=Object.keys(e).filter(i=>l.has(i)&&!r.has(i));return s.length===0||console.warn(`[prompt] ${s.map(i=>`{${i}}`).join(", ")} appears in this prompt and you passed a value for it, but no field declares it, so it was not substituted and the placeholder goes to the model as written. Add it to the prompt's fields.`),s}function z(e){for(let r=e.length-1;r>=0;r--)if(e[r].role==="user")return e[r].content;return""}function _(e,r,l=$){const s=new Set(e.fields.map(o=>o.name)),i=new Set(v(e.blocks,e.question));let w=!1;const u=(o={})=>(w||(w=x(o,s,i).length>0),S(o,e.fields)),M=(o={},f={})=>I(e.blocks,e.question,u(o),f.history),T={id:e.id,name:e.name,slug:e.slug,fields:e.fields,versionId:e.versionId,models:e.models,source:r,serialize:u,format:M,async call(o,f,d={}){const b=u(o),a=I(e.blocks,e.question,b,d.history);j(a);const m=d.tools?.trace(d.query??z(a)),k=d.rolloutId??E(),g=l.enabled&&(d.report??!0),h=Date.now(),y={rolloutId:k,versionId:e.versionId,fields:b,rendered:a};let t;try{t=await f({messages:a,turn:m})}catch(n){if(g){const c=m?.record();l.write(e.id,{...y,model:"unknown",output:"",status:"failed",error:n instanceof Error?n.message:String(n),latencyMs:Date.now()-h,offeredTools:c?.offeredTools,steps:c?.steps})}throw n}const R=Date.now()-h;let p;if(g){const n=m?.record(),c=l.write(e.id,{...y,model:t.model,output:t.output,status:t.status??"completed",error:t.error,inputTokens:t.inputTokens,outputTokens:t.outputTokens,cacheReadTokens:t.cacheReadTokens,cacheWriteTokens:t.cacheWriteTokens,citationTokens:t.citationTokens,reasoningTokens:t.reasoningTokens,latencyMs:R,caseId:t.caseId,seed:t.seed,offeredTools:t.offeredTools??n?.offeredTools,steps:t.steps??n?.steps});p=c instanceof Promise?c:void 0}return{...t,rolloutId:k,...p?{pricing:p}:{}}},bind:o=>q(T,o)};return T}export{A as MAX_RECORDED_MESSAGES,_ as makePrompt,$ as noRecorder};
1
+ import{serializeFields as R}from"@spendgraph/sdk";import{fingerprint as S}from"../fingerprint/index.js";import{findVariables as v,renderMessages as h}from"../render/index.js";import{recordable as E}from"../render/record.js";import{newRolloutId as $}from"../run/id.js";import{bind as j}from"./bind.js";const x={enabled:!1,write:()=>{}},A=50;let D=!1;function z(e){D||e.length<=A||(D=!0,console.warn(`[prompt] ${e.length} messages is over the ${A} a rollout can record. The call still runs; the rollout will be rejected. Trim the history you pass.`))}function O(e,r,l){const s=Object.keys(e).filter(i=>l.has(i)&&!r.has(i));return s.length===0||console.warn(`[prompt] ${s.map(i=>`{${i}}`).join(", ")} appears in this prompt and you passed a value for it, but no field declares it, so it was not substituted and the placeholder goes to the model as written. Add it to the prompt's fields.`),s}function P(e){for(let r=e.length-1;r>=0;r--)if(e[r].role==="user")return e[r].content;return""}function L(e,r,l=x){const s=new Set(e.fields.map(t=>t.name)),i=new Set(v(e.blocks,e.question));let w=!1;const u=(t={})=>(w||(w=O(t,s,i).length>0),R(t,e.fields)),q=(t={},f={})=>h(e.blocks,e.question,u(t),f.history),b={id:e.id,name:e.name,slug:e.slug,fields:e.fields,versionId:e.versionId,fingerprint:S({name:e.name,blocks:e.blocks,question:e.question}),models:e.models,source:r,serialize:u,format:q,async call(t,f,d={}){const k=u(t),a=h(e.blocks,e.question,k,d.history);z(a);const m=d.tools?.trace(d.query??P(a)),T=d.rolloutId??$(),g=l.enabled&&(d.report??!0),y=Date.now(),I=()=>({rolloutId:T,versionId:e.versionId,fields:k,rendered:E(a)});let o;try{o=await f({messages:a,turn:m})}catch(n){if(g){const c=m?.record();l.write(e.id,{...I(),model:"unknown",output:"",status:"failed",error:n instanceof Error?n.message:String(n),latencyMs:Date.now()-y,offeredTools:c?.offeredTools,steps:c?.steps})}throw n}const M=Date.now()-y;let p;if(g){const n=m?.record(),c=l.write(e.id,{...I(),model:o.model,output:o.output,status:o.status??"completed",error:o.error,inputTokens:o.inputTokens,outputTokens:o.outputTokens,cacheReadTokens:o.cacheReadTokens,cacheWriteTokens:o.cacheWriteTokens,citationTokens:o.citationTokens,reasoningTokens:o.reasoningTokens,latencyMs:M,caseId:o.caseId,seed:o.seed,offeredTools:o.offeredTools??n?.offeredTools,steps:o.steps??n?.steps});p=c instanceof Promise?c:void 0}return{...o,rolloutId:T,...p?{pricing:p}:{}}},bind:t=>j(b,t)};return b}export{A as MAX_RECORDED_MESSAGES,L as makePrompt,x as noRecorder};
@@ -1,4 +1,4 @@
1
1
  export { renderMessages } from "./messages.js";
2
2
  export { compileSystemPrompt } from "./system.js";
3
- export type { Block, History, Message } from "./types.js";
3
+ export type { Block, History, Message, MessageCall } from "./types.js";
4
4
  export { applyVariables, findVariables } from "./variables.js";
@@ -0,0 +1,12 @@
1
+ import type { RenderedMessage } from "@spendgraph/sdk";
2
+ import type { Message } from "./types.js";
3
+ /**
4
+ * The messages as a rollout can carry them.
5
+ *
6
+ * A `tool` turn is recorded as itself, carrying the name of whatever answered.
7
+ * What was called and with which arguments is still in `steps`, where it is
8
+ * queryable rather than embedded in a transcript — but the result the model
9
+ * read is part of the conversation, and rewriting it into a `user` message made
10
+ * the transcript claim a person said it.
11
+ */
12
+ export declare function recordable(messages: Message[]): RenderedMessage[];
@@ -0,0 +1 @@
1
+ function n(t){return t.map(o=>o.role==="tool"?{role:"tool",content:o.content,name:o.name??"a tool"}:{role:o.role,content:o.content})}export{n as recordable};
@@ -3,13 +3,29 @@ export type Block = {
3
3
  title: string;
4
4
  body: string;
5
5
  };
6
+ /** A tool call an assistant turn made, kept so its result can be paired back to it. */
7
+ export interface MessageCall {
8
+ id: string;
9
+ name: string;
10
+ args: Record<string, unknown>;
11
+ }
6
12
  /**
7
- * One turn. `assistant` appears only in conversation history — a prompt's own
8
- * wording still renders a system turn and a user turn, never an answer.
13
+ * One turn. `assistant` and `tool` appear only in conversation history — a
14
+ * prompt's own wording still renders a system turn and a user turn, never an
15
+ * answer.
16
+ *
17
+ * Structurally the `PromptMessage` that `@spendgraph/llms` takes, so a
18
+ * conversation that used tools can be handed straight back as history rather
19
+ * than flattened into prose first. Nothing here templates it: history goes to
20
+ * the model as it arrived.
9
21
  */
10
- export type Message = {
11
- role: "system" | "user" | "assistant";
22
+ export interface Message {
23
+ role: "system" | "user" | "assistant" | "tool";
12
24
  content: string;
13
- };
25
+ toolCalls?: MessageCall[];
26
+ toolCallId?: string;
27
+ name?: string;
28
+ isError?: boolean;
29
+ }
14
30
  /** Prior turns, oldest first, placed between the system turn and the question. */
15
31
  export type History = Message[];
@@ -1,7 +1 @@
1
- /**
2
- * Runs `fn` over `items`, at most `limit` in flight.
3
- *
4
- * Bounded on purpose: a whole dataset at once becomes 429s you still wait out,
5
- * so the unbounded version finishes later and flakier.
6
- */
7
- export declare function mapLimit<T, R>(items: T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
1
+ export { mapLimit } from "@spendgraph/sdk";
@@ -1 +1 @@
1
- async function h(n,e,o){const a=new Array(n.length);let r=0;const l=Array.from({length:Math.max(1,Math.min(e,n.length))},async()=>{for(;r<n.length;){const t=r++;a[t]=await o(n[t],t)}});return await Promise.all(l),a}export{h as mapLimit};
1
+ import{mapLimit as o}from"@spendgraph/sdk";export{o as mapLimit};
@@ -18,6 +18,11 @@ export interface Prompt {
18
18
  fields: FieldSpec[];
19
19
  /** What a rollout recorded from this prompt should reference. Null when custom. */
20
20
  versionId: string | null;
21
+ /**
22
+ * A stable id for what this prompt says, the same whether it was pulled or
23
+ * built, so cost history follows the wording across being registered.
24
+ */
25
+ fingerprint: string;
21
26
  models: string[];
22
27
  source: PromptSource;
23
28
  /**
@@ -0,0 +1,46 @@
1
+ export const meta = {
2
+ title: "Caching — spendgraph docs",
3
+ description:
4
+ "`pull` serves a stale copy immediately and refreshes behind the caller. Invalidating has to drop both handles, because the same prompt sits under its uuid and its slug at once.",
5
+ };
6
+
7
+ # Caching, and invalidating it
8
+
9
+ `pull` serves a stale copy immediately and refreshes behind the caller, so an edit takes effect within a TTL without any request paying for the fetch.
10
+
11
+ ## Invalidating drops both handles
12
+
13
+ `promote` invalidates for you. Doing it by hand needs to drop **both** handles, not just the one passed: `pull` keys on whatever string it was given, so the same prompt sits under its uuid and its slug at once — and `promote` is only reachable with a uuid while the dashboard teaches pulling by slug. Dropping one left the other warm for a full TTL, still rendering the wording the promotion replaced.
14
+
15
+ ```ts
16
+ await prompts.promote(promptId, versionId); // invalidates
17
+ prompts.invalidate(promptId); // both handles
18
+ prompts.invalidate(); // everything
19
+ ```
20
+
21
+ The delete runs even when nothing is stored under the handle: a cold isolate has a fetch in flight and no entry yet, and the delete is what stops that in-flight result being stored **after** the promotion.
22
+
23
+ ## A handle that stopped resolving
24
+
25
+ A rename re-mints the slug, so a handle that 404s is routine rather than exotic. The cache backs one off after a failure rather than firing a doomed request on every pull, and keeps serving the entry it has.
26
+
27
+ `onPullError` is worth setting. Without it a background refresh that has stopped resolving is silent, and a rename on the dashboard freezes a running service with nothing anywhere saying so.
28
+
29
+ ```ts
30
+ new PromptClient({
31
+ apiKey,
32
+ baseUrl,
33
+ onPullError: (err, key) => log.warn({ key }, String(err)),
34
+ cache: { ttlSeconds: 300, retryAfterSeconds: 60 },
35
+ });
36
+ ```
37
+
38
+ `cache.identify` is not yours to set — the client fixes it to the prompt's id and slug, because an invalidation arriving under one handle has to reach a fetch started under the other.
39
+
40
+ <Callout tone="trap" title="The dataset is not cached with the prompt">
41
+ Cases change independently of the wording, so they are fetched fresh. A cached prompt and a stale case list would score today's answers against yesterday's questions and report it as an improvement.
42
+ </Callout>
43
+
44
+ ## A reply that is not a prompt
45
+
46
+ `pull` refuses a response carrying no prompt, naming the handle and the two things worth checking — that the base URL points at a spendgraph app, and that the key can read this project. A prompt that is thin but real is still served, with the absent parts as their empty values.
@@ -0,0 +1,58 @@
1
+ export const meta = {
2
+ title: "Calling a prompt — spendgraph docs",
3
+ description:
4
+ "`call` renders, runs the model call you give it, and records what happened around it. What was sent is built once and shared by the success and failure paths.",
5
+ };
6
+
7
+ # Calling a prompt
8
+
9
+ `call` renders, runs the model call **you** give it, and records what happened around it. This package never sees the provider response, which is why the call is handed to it rather than made by it.
10
+
11
+ ```ts
12
+ const reply = await stored.call({ audience: "students", max_words: 50 }, async ({ messages }) => {
13
+ const res = await anthropic.messages.create({ model, max_tokens: 400, messages });
14
+ return { output: res.content[0].text, model, inputTokens: 400, outputTokens: 12 };
15
+ });
16
+ ```
17
+
18
+ It fills in the version, fields, messages, latency and status; you return the text, model and tokens.
19
+
20
+ **Return the tokens** or every cost on the prompt reads as zero — the first call that forgets warns once.
21
+
22
+ ## One record, two paths
23
+
24
+ What was sent is built **once** and shared by the success path and the failure path, so the two cannot disagree about it. That is the thing hand-written reporting gets wrong.
25
+
26
+ A callback that throws is recorded as failed and then rethrown: a provider call that throws is otherwise recorded nowhere at all, which leaves the run most in need of explaining with no evidence.
27
+
28
+ ## Recording
29
+
30
+ Recording is on for every prompt and is fire-and-forget. There is no client-level switch: pass `{ report: false }` on the call that should not be recorded, which is where you know whether it should.
31
+
32
+ Fire-and-forget means nothing catches a send that fails, and what is lost is spend nothing downstream can reconstruct. **`onReportError` is where that arrives** — on the client for a rollout, and on `buildCustomPrompt` for a custom prompt's usage. A client passes its own down to what it builds.
33
+
34
+ ## Tools
35
+
36
+ `call` and `invoke` accept anything that hands out a turn — [`toolbus()`](/docs/tools/overview), or your own.
37
+
38
+ ```ts
39
+ await prompt.call(values, ({ messages, turn }) => call(messages, turn), { tools: bus });
40
+ ```
41
+
42
+ The callback gets the turn **fully typed**; the rollout gets the tools offered and the steps taken. Which tools were offered is recorded as well as which were called: without it a rollout cannot tell "chose not to" from "was never offered", which are opposite bugs.
43
+
44
+ <Callout tone="trap" title="The recording never blocks the call, and never breaks it">
45
+ Reporting happens beside your call, not in front of it: a report that fails reaches `onReportError` rather than your `catch`. That is deliberate — a metering outage must not take an app down — but it does mean a missing rollout is silent unless you wired that callback.
46
+ </Callout>
47
+
48
+ ## Binding a model
49
+
50
+ ```ts
51
+ const chain = prompt.bind(model);
52
+
53
+ await chain.invoke(values);
54
+ await chain.stream(values, { onText: (delta) => res.write(delta) });
55
+ await chain.batch(cases, { concurrency: 4 });
56
+ ```
57
+
58
+ `stream` falls back to `call` for a model that cannot stream, so binding one does not require it.
@@ -0,0 +1,61 @@
1
+ export const meta = {
2
+ title: "Versions and datasets — spendgraph docs",
3
+ description:
4
+ "Cases sit on one side of a wall the server decides, so a case never moves sides. setCases replaces the whole set, because a dataset has one answer at a time.",
5
+ };
6
+
7
+ # Versions and datasets
8
+
9
+ ```ts
10
+ await prompts.versions(promptId, { origin: "user", limit: 20 });
11
+ await prompts.cases(promptId);
12
+ await prompts.setCases(promptId, cases);
13
+ ```
14
+
15
+ ## A case sits on one side of a wall
16
+
17
+ Every case is either `feedback` or `held_out`, and **the server decides which** — it is not a field you set.
18
+
19
+ ```
20
+ feedback ──▶ what an optimiser is allowed to read and learn from
21
+ held_out ──▶ what it never sees, so a score means something
22
+ ```
23
+
24
+ That is the whole point of the split, and the reason the client cannot set it. A case that could move sides is a case that can be quietly moved to the side where the prompt already does well, and every number downstream stops meaning anything. `DatasetCase.split` exists on the type so a read can report it; omit it on a write.
25
+
26
+ ```ts
27
+ const summary = await prompts.cases(promptId);
28
+
29
+ summary.counts; // { total, feedback, held_out }
30
+ summary.problem; // why this cannot be optimised against yet, or null
31
+ ```
32
+
33
+ `problem` is the useful one. A dataset too small, too lopsided, or missing expected outputs cannot support a comparison, and the server says which rather than returning a confident score built on four cases.
34
+
35
+ ## `setCases` replaces everything
36
+
37
+ Not a merge, and deliberately.
38
+
39
+ A dataset is a **set** — "which cases am I evaluating against" has one answer at a time. A merge would leave no way to remove a case, and a stale case nobody can delete quietly biases every run after it.
40
+
41
+ ```ts
42
+ await prompts.setCases(promptId, [
43
+ { caseId: "refund-partial", fieldValues: { audience: "students" }, expected: "…" },
44
+ ]);
45
+ ```
46
+
47
+ Send the set you want to have. Read it back with `cases()` if you need to confirm what landed.
48
+
49
+ <Callout tone="trap" title="A case id is not a label — it decides which side of the wall the case lands on">
50
+ The split is derived from the id, so renaming a case re-rolls it: a case that was held out can come back as feedback, and whatever proposes changes may now read what it is scored against. Nothing in the data would say so. Change the text, keep the id.
51
+ </Callout>
52
+
53
+ ## Versions
54
+
55
+ ```ts
56
+ await prompts.versions(promptId, { origin: "user", limit: 20 });
57
+ ```
58
+
59
+ `origin` separates a version somebody wrote from one a run produced, which matters once an optimiser is writing candidates — otherwise the history reads as though a person made forty edits in a minute.
60
+
61
+ Promoting a version is on [Caching](/docs/prompt/caching), because the part worth knowing is what it invalidates.
@@ -0,0 +1,63 @@
1
+ export const meta = {
2
+ title: "Fields — spendgraph docs",
3
+ description:
4
+ "A placeholder is declared as a FieldSpec. A missing required field throws before anything is sent, and a value with nowhere to land is said rather than dropped.",
5
+ };
6
+
7
+ # Fields
8
+
9
+ A `{placeholder}` is declared as a `FieldSpec` — a name, a type, whether it is required, and how it renders. A missing required field throws **before anything is sent**.
10
+
11
+ What it throws is `FieldValidationError`, re-exported from the SDK so a caller has one name to catch whether the failure came from a prompt, a tool argument or a graph input:
12
+
13
+ ```ts
14
+ import { FieldValidationError } from "@spendgraph/prompt";
15
+
16
+ try {
17
+ prompt.format(values);
18
+ } catch (err) {
19
+ if (err instanceof FieldValidationError) err.errors; // which fields, and why
20
+ }
21
+ ```
22
+
23
+ ```ts
24
+ prompt.format(values); // Message[], validated and substituted
25
+ prompt.serialize(values); // the field map that format used
26
+ ```
27
+
28
+ Types come from `@spendgraph/sdk`, because a tool's arguments and a graph node's inputs are the same problem.
29
+
30
+ ## Only declared fields are substituted
31
+
32
+ A `{placeholder}` nobody supplied is **left as it is**. Emptying it would send a prompt with a hole the caller cannot see, and noise that reads as content is worse than a visible gap.
33
+
34
+ A value you *did* pass with no field to land in is a different thing. The first time that happens, the prompt says so:
35
+
36
+ ```
37
+ [prompt] {audience} appears in this prompt and you passed a value for it, but no
38
+ field declares it, so it was not substituted and the placeholder goes to the
39
+ model as written. Add it to the prompt's fields.
40
+ ```
41
+
42
+ All three conditions have to hold — the value was supplied, the placeholder is in the wording, and no field declares it — so an unrelated value and an unsupplied placeholder both stay quiet.
43
+
44
+ ## Substitution reads own properties only
45
+
46
+ `{constructor}`, `{toString}` and `{valueOf}` are not supplied just because every object has them. A template about code is all it takes to send `explain what function Object() { [native code] } does` to a model, and that is noise dressed as content.
47
+
48
+ <Callout tone="trap" title="A value that looks like a placeholder stays text">
49
+ Substitution runs once and does not re-read what it wrote, so a field whose value is `{other}` reaches the model as those eight characters. The alternative is a value from a user deciding which of your other fields to expand.
50
+ </Callout>
51
+
52
+ ## Conversation history
53
+
54
+ Prior turns render between the system turn and the question, oldest first.
55
+
56
+ ```ts
57
+ prompt.format(values, { history });
58
+ await prompt.call(values, ({ messages }) => call(messages), { history });
59
+ ```
60
+
61
+ History is passed through **verbatim** — it is a record of what was already said, not a template, so `{placeholders}` inside it are left alone. A `system` turn in the history is dropped: the prompt owns that turn, and two of them is a different request from the one the prompt describes.
62
+
63
+ A rollout records at most 50 messages, which is what the route accepts. A longer conversation still runs; only its rollout is refused, and that refusal reaches `onReportError` rather than your call — so the first time it happens the package warns, once, while somebody is still watching. `MAX_RECORDED_MESSAGES` is exported if you want to trim before it bites.
@@ -0,0 +1,78 @@
1
+ export const meta = {
2
+ title: "Prompts — spendgraph docs",
3
+ description:
4
+ "Pull a stored prompt or write one in code, render it, and record what it cost. Both hand back the same object, so nothing downstream has to know which it was given.",
5
+ };
6
+
7
+ # Prompts
8
+
9
+ The wording that produced last week's answer is not the wording in the file today, and a prompt edited since is a score nobody can reproduce.
10
+
11
+ Pull a prompt from the dashboard or write one in code, render it, and record what the call cost — with the exact messages that were sent kept beside the reply.
12
+
13
+ ```sh
14
+ npm install @spendgraph/prompt
15
+ ```
16
+
17
+ ## Two ways in, one prompt
18
+
19
+ `pullPrompt` fetches the wording from the server. `buildCustomPrompt` declares it in code. Both hand back the same `Prompt`, so `format`, `serialize` and `call` behave identically and nothing downstream has to know which it was given.
20
+
21
+ ```ts
22
+ import { PromptClient, buildCustomPrompt } from "@spendgraph/prompt";
23
+
24
+ const prompts = new PromptClient({
25
+ apiKey: process.env.SPENDGRAPH_API_KEY,
26
+ baseUrl: "https://spendgraph.locusgraph.com",
27
+ });
28
+
29
+ const stored = await prompts.pull("billing-explainer");
30
+
31
+ const local = buildCustomPrompt({
32
+ name: "billing-explainer",
33
+ blocks: [{ title: "Role", body: "You explain invoices to {audience}." }],
34
+ question: "Explain this line item in {max_words} words.",
35
+ fields: [
36
+ { name: "audience", type: "string", required: true },
37
+ { name: "max_words", type: "number", required: true },
38
+ ],
39
+ });
40
+ ```
41
+
42
+ `pull` takes an id or a slug — the server resolves either. `pullPrompt` is the same fetch as a standalone function, for a caller that wants one prompt and no client to hold.
43
+
44
+ ## Running one
45
+
46
+ Bind a model and the prompt runs itself:
47
+
48
+ ```ts
49
+ import { Llm } from "@spendgraph/llms";
50
+
51
+ const chain = stored.bind(new Llm({ client: anthropic, model: "claude-opus-5" }));
52
+
53
+ const answer = await chain.invoke({ audience: "students", max_words: 50 });
54
+ answer.output;
55
+ answer.rolloutId;
56
+ ```
57
+
58
+ `bind` asks for a **shape, not an import** — anything with `call(messages)` returning an outcome satisfies `Model`, and an `LlmReply` already is one. Neither package depends on the other.
59
+
60
+ ## What each kind records
61
+
62
+ A **server** prompt has a row and a version behind it, so a call writes a rollout. A **custom** prompt has neither, so it records usage instead: the tokens still land on the dashboard, attributed to the model and tagged with the prompt's name; only the wording goes unversioned.
63
+
64
+ Read `prompt.source` to tell them apart.
65
+
66
+ <Callout tone="trap" title="A custom prompt has no version, so its spend is usage rather than a rollout">
67
+ `buildCustomPrompt` records the call and the cost exactly as a stored one does — but there is no row and no version, so nothing ties the number to a wording you can go back to. Fine while you are exploring; not what you want under a score you intend to compare next month.
68
+ </Callout>
69
+
70
+ ## Where to go next
71
+
72
+ | | |
73
+ | --- | --- |
74
+ | [Fields](/docs/prompt/fields) | placeholders, types, and what is substituted |
75
+ | [Calling a prompt](/docs/prompt/calling) | `call`, history, tools, and what gets recorded |
76
+ | [Caching](/docs/prompt/caching) | serving stale, refreshing behind, invalidating both handles |
77
+ | [Running server-side](/docs/prompt/running) | `run`, `sample`, `runAll`, and the spend ceiling |
78
+ | [Versions and datasets](/docs/prompt/datasets) | the split the server decides, and why it does |
@@ -0,0 +1,55 @@
1
+ export const meta = {
2
+ title: "Running server-side — spendgraph docs",
3
+ description:
4
+ "run, sample and runAll execute the prompt where the spec, rendering and pricing already live. The batch path, and the ceiling that stops it running away.",
5
+ };
6
+
7
+ # Running server-side
8
+
9
+ `run`, `sample` and `runAll` execute the prompt **on the server**, where the spec, rendering and pricing already live.
10
+
11
+ This is the batch path. A production request should pull, render and report instead: the server-side call adds a hop and makes spendgraph a dependency of your uptime, which is the right trade for evaluation and the wrong one for a user waiting on a response.
12
+
13
+ ```ts
14
+ await prompts.run(promptId, values);
15
+ await prompts.sample(promptId, values, { k: 5 });
16
+ await prompts.runAll(promptId, values); // one rollout per saved model
17
+ ```
18
+
19
+ ## Every seed, and every model, comes back
20
+
21
+ `sample` always returns **k results**. A failed seed is present with `status: "failed"` rather than missing — three results for k = 5 would let a metric compute over three and report better consistency than the run showed.
22
+
23
+ `runAll` does the same across models. One slug with a typo used to take the whole comparison with it — and not stop the calls already queued, which still left, landed and were billed after the caller had been told the run failed. Now each model comes back in the order asked, a failed one carrying its own name and the provider's message.
24
+
25
+ A **budget stop** is the exception: it throws `BudgetExceededError`, carrying what was spent and what the ceiling was. Money running out says nothing about a prompt or a model, and recording those as failures would put the wallet into `pass^k`.
26
+
27
+ ```ts
28
+ import { BudgetExceededError } from "@spendgraph/prompt";
29
+
30
+ catch (err) {
31
+ if (err instanceof BudgetExceededError) {
32
+ err.spentMicros; // integer micro-USD, as everywhere else
33
+ err.limitMicros;
34
+ }
35
+ }
36
+ ```
37
+
38
+ ## The spend ceiling
39
+
40
+ `maxCostMicros` caps what those spend. Omit it and the client warns once that it is unbounded; pass `null` to say you meant it.
41
+
42
+ ```ts
43
+ new PromptClient({ apiKey, baseUrl, maxCostMicros: 5_000_000 }); // $5
44
+ new PromptClient({ apiKey, baseUrl, maxCostMicros: null }); // deliberately none
45
+ ```
46
+
47
+ `0` is kept, because spending nothing is a thing a caller can mean. A ceiling that is `NaN` or negative is **refused**: `NaN >= NaN` is false, so a `NaN` limit never fires and the guard is silently not there — and `Number(process.env.MAX_COST_MICROS)` on an unset variable is `NaN`, which is how it arrives without anyone typing it.
48
+
49
+ <Callout tone="trap" title="Omit `maxCostMicros` and the client warns once, then spends without a ceiling">
50
+ `runAll` fans out across every saved model and `sample` multiplies that by k, so the bill is a product of two numbers neither of which is in front of you. Pass a ceiling, or pass `null` to say you meant it — the warning appears once per process and is easy to scroll past.
51
+ </Callout>
52
+
53
+ ## What you run against
54
+
55
+ Cases and versions have their own page: [Versions and datasets](/docs/prompt/datasets).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spendgraph/prompt",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Pull a stored prompt or build one in code, render it, and record what it cost.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -27,6 +27,7 @@
27
27
  "types": "./dist/index.d.ts",
28
28
  "import": "./dist/index.js"
29
29
  },
30
+ "./docs/*": "./docs/*",
30
31
  "./render": {
31
32
  "types": "./dist/render/index.d.ts",
32
33
  "import": "./dist/render/index.js"
@@ -39,19 +40,16 @@
39
40
  },
40
41
  "files": [
41
42
  "dist",
43
+ "docs",
42
44
  "README.md"
43
45
  ],
44
- "scripts": {
45
- "build": "rm -rf dist && tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.json --declaration false --removeComments && node ../../scripts/minify.mjs dist",
46
- "test": "npm run build && vitest run",
47
- "pretest": "npm run build --workspace @spendgraph/tools --workspace @spendgraph/llms"
48
- },
49
46
  "dependencies": {
50
- "@spendgraph/sdk": "^0.6.0"
47
+ "@spendgraph/sdk": "^0.8.0"
51
48
  },
52
49
  "devDependencies": {
53
- "@spendgraph/llms": "^0.6.0",
54
- "@spendgraph/tools": "^0.6.0",
50
+ "@spendgraph/config": "0.8.0",
51
+ "@spendgraph/llms": "^0.8.0",
52
+ "@spendgraph/tools": "^0.8.0",
55
53
  "typescript": "^5"
56
54
  },
57
55
  "engines": {
@@ -59,5 +57,10 @@
59
57
  },
60
58
  "publishConfig": {
61
59
  "access": "public"
60
+ },
61
+ "scripts": {
62
+ "build": "rm -rf dist && tsc -p tsconfig.json --emitDeclarationOnly && tsc -p tsconfig.json --declaration false --removeComments && node ../../scripts/minify.mjs dist",
63
+ "test": "vitest run",
64
+ "typecheck": "tsc -p tsconfig.tests.json"
62
65
  }
63
- }
66
+ }