Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ jobs:
run: node scripts/check-cohort-ranges.mjs

- name: Build tcloud
run: pnpm --filter @tangle-network/tcloud build
run: pnpm --filter '@tangle-network/tcloud...' build

- name: Test tcloud
run: pnpm --filter @tangle-network/tcloud test
Expand Down
8 changes: 4 additions & 4 deletions packages/tcloud-agent/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@tangle-network/tcloud-agent",
"version": "0.4.0",
"description": "Agent run-loop primitive over Tangle sandbox transports — runs an AgentProfile against a brief with criterion gates, budget caps, and streaming events. Includes TangleToolProvider for Pi tool integration.",
"version": "0.5.0",
"description": "Agent run-loop primitive over Tangle sandbox transports \u2014 runs an AgentProfile against a brief with criterion gates, budget caps, and streaming events. Includes TangleToolProvider for Pi tool integration.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down Expand Up @@ -40,7 +40,7 @@
},
"dependencies": {
"@sinclair/typebox": "^0.34.52",
"@tangle-network/sandbox": ">=0.34.3 <0.35.0",
"@tangle-network/sandbox": ">=0.54.2 <0.55.0",
"@tangle-network/tcloud": "workspace:^",
"viem": "^2.56.0"
},
Expand Down Expand Up @@ -86,6 +86,6 @@
"registry": "https://registry.npmjs.org/"
},
"engines": {
"node": ">=20.0.0"
"node": ">=20.19.0"
}
}
13 changes: 13 additions & 0 deletions packages/tcloud/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Changelog

## 0.6.0

Requires Node 20.19 or later. GTR proof uses Node 22.

- Forward chat, streaming-chat and search cancellation to the actual HTTP transport and response body. Abort retry waits without another attempt. Preserve cancellation through the relayer.
- Expose per-response `tangle.costUsd` with a receipt/rates source. Never label the legacy aggregate fallback estimate as a reported cost. This avoids concurrent-call accounting races in consumers.
- Meter billed response headers even when token usage is absent. Apply aggregate spend limits to those charges.
- Move the SDK and Pi package to the published Sandbox 0.54 line. No Sandbox REST facade or agent-runner consolidation is included.
- Retain the Pi raw-HTTP deletion already merged in #58.

Knowledge's transport migration must install this published release before it can use these APIs. A packed candidate is only a pre-release build check, not a registry publication.
6 changes: 3 additions & 3 deletions packages/tcloud/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tangle-network/tcloud",
"version": "0.5.3",
"version": "0.6.0",
"description": "TypeScript SDK and CLI for Tangle Router, Sandbox, model routing, and agent service calls",
"type": "module",
"main": "./dist/index.cjs",
Expand Down Expand Up @@ -77,7 +77,7 @@
"prepublishOnly": "node ../../scripts/check-cohort-ranges.mjs && npm run build"
},
"dependencies": {
"@tangle-network/sandbox": ">=0.34.3 <0.35.0",
"@tangle-network/sandbox": ">=0.54.2 <0.55.0",
"@tangle-network/tcloud-attestation": "workspace:^",
"commander": "^14.0.3",
"viem": "^2.56.0"
Expand Down Expand Up @@ -120,6 +120,6 @@
"tangle-router"
],
"engines": {
"node": ">=18"
"node": ">=20.19.0"
}
}
84 changes: 68 additions & 16 deletions packages/tcloud/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ async function proxiedFetch(
return fetch(`${privacy.relayerUrl}${proxyPath}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: init.signal,
body: JSON.stringify({
target: url,
body: typeof init.body === 'string' ? JSON.parse(init.body) : init.body,
Expand All @@ -131,6 +132,32 @@ async function proxiedFetch(
})
}

/** A retry wait owned by the SDK, cancellable without leaving a timer behind. */
function retryDelay(ms: number, signal?: AbortSignal | null): Promise<void> {
return new Promise((resolve, reject) => {
const abort = () => {
clearTimeout(timer)
signal?.removeEventListener('abort', abort)
reject(signal?.reason ?? new DOMException('Aborted', 'AbortError'))
}
const timer = setTimeout(() => {
signal?.removeEventListener('abort', abort)
resolve()
}, ms)
signal?.addEventListener('abort', abort, { once: true })
if (signal?.aborted) abort()
})
}

/** Absent, malformed, negative and infinite amounts are not a zero-cost receipt. */
function nonnegativeAmount(value: unknown): number | undefined {
if (value === null || value === undefined || value === '') return undefined
if (typeof value !== 'number' && typeof value !== 'string') return undefined
if (typeof value === 'string' && value.trim() === '') return undefined
const amount = Number(value)
return Number.isFinite(amount) && amount >= 0 ? amount : undefined
}

const DEFAULT_RETRY: Required<RetryConfig> = {
maxRetries: 3,
initialBackoffMs: 500,
Expand Down Expand Up @@ -420,23 +447,36 @@ export class TCloudClient {
/** Track cost after a response, using actual pricing from response headers when available */
private trackCost(completion: ChatCompletion, res?: Response) {
this._requestCount++
if (completion.usage) {
let estimatedCost: number
const inputPrice = res ? parseFloat(res.headers.get('x-tangle-price-input') || '0') : 0
const outputPrice = res ? parseFloat(res.headers.get('x-tangle-price-output') || '0') : 0

if (inputPrice > 0 || outputPrice > 0) {
estimatedCost = (completion.usage.prompt_tokens || 0) * inputPrice
+ (completion.usage.completion_tokens || 0) * outputPrice
const usage = completion.usage
const billed = nonnegativeAmount(res?.headers.get('x-tangle-cost-usd'))
?? nonnegativeAmount(usage?.billed_cost)
?? nonnegativeAmount(usage?.cost)
let estimatedCost: number

if (billed !== undefined) {
estimatedCost = billed
completion.tangle = { costUsd: billed, costSource: 'receipt' }
} else if (usage) {
const inputPrice = nonnegativeAmount(res?.headers.get('x-tangle-price-input'))
const outputPrice = nonnegativeAmount(res?.headers.get('x-tangle-price-output'))
const inputTokens = nonnegativeAmount(usage.prompt_tokens)
const outputTokens = nonnegativeAmount(usage.completion_tokens)

if (inputPrice !== undefined && outputPrice !== undefined
&& inputTokens !== undefined && outputTokens !== undefined) {
estimatedCost = inputTokens * inputPrice + outputTokens * outputPrice
completion.tangle = { costUsd: estimatedCost, costSource: 'rates' }
} else {
const tokens = completion.usage.total_tokens || 0
const tokens = usage.total_tokens || 0
estimatedCost = tokens * 0.000001 // $1/M tokens fallback
}
this._totalSpent += estimatedCost
} else {
return
}
this._totalSpent += estimatedCost

if (this.limits?.maxCostPerRequest && estimatedCost > this.limits.maxCostPerRequest) {
this.limits.onLimitReached?.({ type: 'cost', current: estimatedCost, limit: this.limits.maxCostPerRequest })
}
if (this.limits?.maxCostPerRequest && estimatedCost > this.limits.maxCostPerRequest) {
this.limits.onLimitReached?.({ type: 'cost', current: estimatedCost, limit: this.limits.maxCostPerRequest })
}
}

Expand All @@ -449,6 +489,7 @@ export class TCloudClient {
const maxAttempts = retry ? retry.maxRetries + 1 : 1

for (let attempt = 0; attempt < maxAttempts; attempt++) {
init.signal?.throwIfAborted()
const controller = new AbortController()
let timer: ReturnType<typeof setTimeout> | undefined
if (this.timeoutMs > 0 && !streaming) {
Expand All @@ -458,7 +499,9 @@ export class TCloudClient {
try {
const res = await proxiedFetch(this.privacy, url, {
...init,
signal: controller.signal,
signal: init.signal
? AbortSignal.any([init.signal, controller.signal])
: controller.signal,
}, streaming)

if (res.ok) return res
Expand All @@ -470,22 +513,25 @@ export class TCloudClient {
retry.maxBackoffMs,
)
const jitter = backoff * 0.5 * Math.random()
await new Promise(r => setTimeout(r, backoff + jitter))
await res.body?.cancel().catch(() => {})
await retryDelay(backoff + jitter, init.signal)
continue
}

// Not retryable or exhausted retries
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new TCloudError(res.status, err.error?.message || err.error || err.message || res.statusText)
} catch (e: any) {
// Caller cancellation is not a timeout and must never start another attempt.
init.signal?.throwIfAborted()
if (e instanceof TCloudError) throw e
// Timeout and network errors are retryable
if (retry && attempt < retry.maxRetries) {
const backoff = Math.min(
retry.initialBackoffMs * Math.pow(retry.multiplier, attempt),
retry.maxBackoffMs,
)
await new Promise(r => setTimeout(r, backoff))
await retryDelay(backoff, init.signal)
continue
}
if (e?.name === 'AbortError') {
Expand Down Expand Up @@ -626,6 +672,7 @@ export class TCloudClient {

/** Chat completion (non-streaming) */
async chat(options: ChatOptions): Promise<ChatCompletion> {
options.signal?.throwIfAborted()
this.checkLimits()
const { headers, baseURL } = await this._prepareChatRequest(
this._effectiveModel(options),
Expand All @@ -636,9 +683,12 @@ export class TCloudClient {
method: 'POST',
headers,
body: this._chatBody(options, false),
signal: options.signal,
}, false)

const completion: ChatCompletion = await res.json()
// Only this SDK may mark a per-response receipt. Never trust an upstream extension.
delete completion.tangle
this.trackCost(completion, res)
return completion
}
Expand All @@ -656,6 +706,7 @@ export class TCloudClient {
method: 'POST',
headers,
body: this._chatBody(options, true),
signal: options.signal,
}, true)

const reader = res.body!.getReader()
Expand Down Expand Up @@ -983,6 +1034,7 @@ export class TCloudClient {
async search(options: SearchOptions): Promise<SearchResponse> {
return this._request(`${this.baseURL}/search`, {
method: 'POST',
signal: options.signal,
body: JSON.stringify({
query: options.query,
provider: options.provider,
Expand Down
8 changes: 8 additions & 0 deletions packages/tcloud/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ export type SearchProvider = 'perplexity' | 'exa' | 'you' | 'parallel' | 'tavily
export type SearchRecency = 'day' | 'week' | 'month' | 'year'

export interface SearchOptions {
/** Cancel transport, response-body reads and retry waits. Never serialized. */
signal?: AbortSignal
query: string
provider?: SearchProvider
/** Alias accepted by the Router for provider-compatible clients. */
Expand Down Expand Up @@ -509,6 +511,8 @@ export interface SandboxChatOptions {
}

export interface ChatOptions {
/** Cancel transport, response-body reads and retry waits. Never serialized. */
signal?: AbortSignal
/** Model to use */
model?: string
/** Messages */
Expand Down Expand Up @@ -562,6 +566,8 @@ export interface ChatOptions {
}

export interface ChatCompletion {
/** SDK receipt for this response only. Absent when cost is only guessed. */
tangle?: { costUsd: number; costSource: 'receipt' | 'rates' }
id: string
object: string
created: number
Expand All @@ -575,6 +581,8 @@ export interface ChatCompletion {
prompt_tokens: number
completion_tokens: number
total_tokens: number
billed_cost?: number
cost?: number
}
}

Expand Down
18 changes: 18 additions & 0 deletions packages/tcloud/tests/chat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,24 @@ describe('chat()', () => {
expect(client.usage.requestCount).toBe(1)
})

it('meters a billed header without token usage and blocks the next paid request', async () => {
const { usage: _usage, ...body } = COMPLETION
const fn = mockFetchJson(body, { 'x-tangle-cost-usd': '0.033' })
globalThis.fetch = fn
const reached = vi.fn()
const client = new TCloudClient({
apiKey: 'sk-tan-test',
limits: { maxTotalSpend: 0.03, maxCostPerRequest: 0.02, onLimitReached: reached },
})
const result = await client.chat({ messages: [{ role: 'user', content: 'hi' }] })
expect(result.tangle).toEqual({ costUsd: 0.033, costSource: 'receipt' })
expect(client.usage.totalSpent).toBe(0.033)
expect(reached).toHaveBeenCalledWith({ type: 'cost', current: 0.033, limit: 0.02 })
await expect(client.chat({ messages: [{ role: 'user', content: 'again' }] }))
.rejects.toMatchObject({ status: 429 })
expect(fn).toHaveBeenCalledTimes(1)
})

it('passes tools and toolChoice', async () => {
globalThis.fetch = mockFetchJson(COMPLETION)
const client = new TCloudClient({ apiKey: 'sk-tan-test' })
Expand Down
Loading
Loading