diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 15e878b..6329fa3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/packages/tcloud-agent/package.json b/packages/tcloud-agent/package.json index 1274601..9016d78 100644 --- a/packages/tcloud-agent/package.json +++ b/packages/tcloud-agent/package.json @@ -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", @@ -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" }, @@ -86,6 +86,6 @@ "registry": "https://registry.npmjs.org/" }, "engines": { - "node": ">=20.0.0" + "node": ">=20.19.0" } } diff --git a/packages/tcloud/CHANGELOG.md b/packages/tcloud/CHANGELOG.md new file mode 100644 index 0000000..9c2ede4 --- /dev/null +++ b/packages/tcloud/CHANGELOG.md @@ -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. diff --git a/packages/tcloud/package.json b/packages/tcloud/package.json index 13c8c17..c3bcdbe 100644 --- a/packages/tcloud/package.json +++ b/packages/tcloud/package.json @@ -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", @@ -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" @@ -120,6 +120,6 @@ "tangle-router" ], "engines": { - "node": ">=18" + "node": ">=20.19.0" } } diff --git a/packages/tcloud/src/client.ts b/packages/tcloud/src/client.ts index 29cda28..2650c49 100644 --- a/packages/tcloud/src/client.ts +++ b/packages/tcloud/src/client.ts @@ -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, @@ -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 { + 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 = { maxRetries: 3, initialBackoffMs: 500, @@ -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 }) } } @@ -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 | undefined if (this.timeoutMs > 0 && !streaming) { @@ -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 @@ -470,7 +513,8 @@ 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 } @@ -478,6 +522,8 @@ export class TCloudClient { 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) { @@ -485,7 +531,7 @@ export class TCloudClient { 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') { @@ -626,6 +672,7 @@ export class TCloudClient { /** Chat completion (non-streaming) */ async chat(options: ChatOptions): Promise { + options.signal?.throwIfAborted() this.checkLimits() const { headers, baseURL } = await this._prepareChatRequest( this._effectiveModel(options), @@ -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 } @@ -656,6 +706,7 @@ export class TCloudClient { method: 'POST', headers, body: this._chatBody(options, true), + signal: options.signal, }, true) const reader = res.body!.getReader() @@ -983,6 +1034,7 @@ export class TCloudClient { async search(options: SearchOptions): Promise { return this._request(`${this.baseURL}/search`, { method: 'POST', + signal: options.signal, body: JSON.stringify({ query: options.query, provider: options.provider, diff --git a/packages/tcloud/src/types.ts b/packages/tcloud/src/types.ts index 1e8235d..e08132e 100644 --- a/packages/tcloud/src/types.ts +++ b/packages/tcloud/src/types.ts @@ -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. */ @@ -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 */ @@ -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 @@ -575,6 +581,8 @@ export interface ChatCompletion { prompt_tokens: number completion_tokens: number total_tokens: number + billed_cost?: number + cost?: number } } diff --git a/packages/tcloud/tests/chat.test.ts b/packages/tcloud/tests/chat.test.ts index 985dc4b..d50680f 100644 --- a/packages/tcloud/tests/chat.test.ts +++ b/packages/tcloud/tests/chat.test.ts @@ -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' }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea91c2f..e04e2f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,8 +28,8 @@ importers: packages/tcloud: dependencies: '@tangle-network/sandbox': - specifier: '>=0.34.3 <0.35.0' - version: 0.34.3(viem@2.56.0(typescript@5.9.3)(zod@4.4.3)) + specifier: '>=0.54.2 <0.55.0' + version: 0.54.2(viem@2.56.0(typescript@5.9.3)(zod@4.5.4)) '@tangle-network/tcloud-attestation': specifier: workspace:^ version: link:../tcloud-attestation @@ -38,7 +38,7 @@ importers: version: 14.0.3 viem: specifier: ^2.56.0 - version: 2.56.0(typescript@5.9.3)(zod@4.4.3) + version: 2.56.0(typescript@5.9.3)(zod@4.5.4) devDependencies: '@types/node': specifier: ^22.0.0 @@ -65,14 +65,14 @@ importers: specifier: ^0.34.52 version: 0.34.52 '@tangle-network/sandbox': - specifier: '>=0.34.3 <0.35.0' - version: 0.34.3(viem@2.56.0(typescript@5.9.3)(zod@4.4.3)) + specifier: '>=0.54.2 <0.55.0' + version: 0.54.2(viem@2.56.0(typescript@5.9.3)(zod@4.5.4)) '@tangle-network/tcloud': specifier: workspace:^ version: link:../tcloud viem: specifier: ^2.56.0 - version: 2.56.0(typescript@5.9.3)(zod@4.4.3) + version: 2.56.0(typescript@5.9.3)(zod@4.5.4) devDependencies: '@types/node': specifier: ^22.0.0 @@ -323,6 +323,10 @@ packages: resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@oxc-project/types@0.147.0': resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} @@ -574,23 +578,23 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@tangle-network/agent-core@0.9.5': - resolution: {integrity: sha512-F+frKU7BqKANuXVcI4+mu93HrzCe8/hBzRz0R1UGG0poe2Rbrud4w4L3mFqd/7I06Qzi3eLnrxgXyPwBh5VKzw==} + '@tangle-network/agent-core@0.9.7': + resolution: {integrity: sha512-lKnHpz09tk4wI81Nr10ijq8psakIrqrySWu/giHuH6okeI0jE4zMb0W0XqI//N82W6GHGEZ6Xr2iAr9fGkQHGw==} peerDependencies: '@modelcontextprotocol/sdk': ^1.30.0 peerDependenciesMeta: '@modelcontextprotocol/sdk': optional: true - '@tangle-network/agent-interface@1.9.0': - resolution: {integrity: sha512-HB43+EjiMFYQ+sA4h6FNd1pP81jm6HmOfo5Enfd9FZm0NrTWufK2WbbCUpoGgrnbG1vJv8h/Ly/YevnGTZ5baw==} + '@tangle-network/agent-interface@2.13.0': + resolution: {integrity: sha512-gBOlHyw7iDbMBLLqmZqIZ2hVg9bRBOU5Hmyo31w5sGOWxXQYqSAwqqUlGe5klHRefOW7souT71i5B/dEKdbbFA==} - '@tangle-network/sandbox@0.34.3': - resolution: {integrity: sha512-Zy+DsmQD6qG+2yf8Zox3TCLVAcMOUwQsiVs9uz6OTb1rrz3yDemdoT3dGNUrpbV/wmt/62K+e/5nb7E0F2xNfg==} + '@tangle-network/sandbox@0.54.2': + resolution: {integrity: sha512-TGMZlsnQXA/dDoE6M8t5RMLSypFcw3h3TNZCPmHh/MZyZKMugyX6WSmH4xaWSkBXj0hewMkXjm7PJpInx/17LQ==} peerDependencies: '@mastra/core': ^1.36.0 '@modelcontextprotocol/sdk': ^1.30.0 - ai: ^6.0.175 + ai: ^6.0.175 || ^7.0.107 openai: ^6.36.0 viem: ^2.0.0 peerDependenciesMeta: @@ -1223,8 +1227,8 @@ packages: engines: {node: '>= 14.6'} hasBin: true - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} snapshots: @@ -1344,6 +1348,8 @@ snapshots: '@noble/hashes@1.8.0': {} + '@noble/hashes@2.4.0': {} + '@oxc-project/types@0.147.0': {} '@rolldown/binding-android-arm-eabi@1.2.6': @@ -1505,24 +1511,24 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@tangle-network/agent-core@0.9.5': + '@tangle-network/agent-core@0.9.7': dependencies: - '@tangle-network/agent-interface': 1.9.0 - zod: 4.4.3 + '@tangle-network/agent-interface': 2.13.0 + zod: 4.5.4 - '@tangle-network/agent-interface@1.9.0': + '@tangle-network/agent-interface@2.13.0': dependencies: - '@noble/hashes': 1.8.0 + '@noble/hashes': 2.4.0 spdx-expression-parse: 5.0.0 - zod: 4.4.3 + zod: 4.5.4 - '@tangle-network/sandbox@0.34.3(viem@2.56.0(typescript@5.9.3)(zod@4.4.3))': + '@tangle-network/sandbox@0.54.2(viem@2.56.0(typescript@5.9.3)(zod@4.5.4))': dependencies: - '@tangle-network/agent-core': 0.9.5 - '@tangle-network/agent-interface': 1.9.0 - zod: 4.4.3 + '@tangle-network/agent-core': 0.9.7 + '@tangle-network/agent-interface': 2.13.0 + zod: 4.5.4 optionalDependencies: - viem: 2.56.0(typescript@5.9.3)(zod@4.4.3) + viem: 2.56.0(typescript@5.9.3)(zod@4.5.4) '@types/chai@5.2.3': dependencies: @@ -1588,10 +1594,10 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - abitype@1.2.3(typescript@5.9.3)(zod@4.4.3): + abitype@1.2.3(typescript@5.9.3)(zod@4.5.4): optionalDependencies: typescript: 5.9.3 - zod: 4.4.3 + zod: 4.5.4 acorn@8.16.0: {} @@ -1818,7 +1824,7 @@ snapshots: obug@2.1.1: {} - ox@0.14.34(typescript@5.9.3)(zod@4.4.3): + ox@0.14.34(typescript@5.9.3)(zod@4.5.4): dependencies: '@adraffy/ens-normalize': 1.11.1 '@noble/ciphers': 1.3.0 @@ -1826,7 +1832,7 @@ snapshots: '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3) + abitype: 1.2.3(typescript@5.9.3)(zod@4.5.4) eventemitter3: 5.0.1 optionalDependencies: typescript: 5.9.3 @@ -2035,15 +2041,15 @@ snapshots: undici-types@6.21.0: {} - viem@2.56.0(typescript@5.9.3)(zod@4.4.3): + viem@2.56.0(typescript@5.9.3)(zod@4.5.4): dependencies: '@noble/curves': 1.9.1 '@noble/hashes': 1.8.0 '@scure/bip32': 1.7.0 '@scure/bip39': 1.6.0 - abitype: 1.2.3(typescript@5.9.3)(zod@4.4.3) + abitype: 1.2.3(typescript@5.9.3)(zod@4.5.4) isows: 1.0.7(ws@8.21.3) - ox: 0.14.34(typescript@5.9.3)(zod@4.4.3) + ox: 0.14.34(typescript@5.9.3)(zod@4.5.4) ws: 8.21.3 optionalDependencies: typescript: 5.9.3 @@ -2113,4 +2119,4 @@ snapshots: yaml@2.9.0: {} - zod@4.4.3: {} + zod@4.5.4: {} diff --git a/scripts/prove-pi-live.mjs b/scripts/prove-pi-live.mjs new file mode 100644 index 0000000..5b32771 --- /dev/null +++ b/scripts/prove-pi-live.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +// Run in a fresh HOME so an unrelated saved wallet cannot replace the API key. +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +assert.ok(process.env.TANGLE_API_KEY, 'Set a funded TANGLE_API_KEY without printing it') +assert.equal(existsSync(join(process.env.HOME ?? '', '.tcloud', 'wallets.json')), false, 'Use a fresh HOME for this key-funded proof') +const tools = new Map() +const events = new Map() +const { default: extension } = await import('../packages/tcloud-agent/dist/pi-extension.js') +extension({ + on(name, callback) { events.set(name, callback) }, + registerTool(tool) { tools.set(tool.name, tool) }, +}) +const ctx = { hasUI: false, sessionManager: { getSessionId: () => 'sweep4-live-proof' } } +await events.get('session_start')?.({}, ctx) +const tool = tools.get('tangle') +assert.ok(tool, 'Pi did not register the real tangle tool') +const marker = `pi-tcloud-ok-${randomUUID()}` +const input = { + model: process.env.TCLOUD_MODEL || 'gpt-4o-mini', + messages: [{ role: 'user', content: `Reply with exactly ${marker}` }], + max_tokens: 1200, +} +const result = await tool.execute('sweep4-proof', { capability: 'chat', input }, undefined, undefined, ctx) +assert.notEqual(result.isError, true, 'Pi tool returned an error') +const text = result.content.filter(part => part.type === 'text').map(part => part.text).join('\n') +assert.ok(text.includes(marker), 'Reply omitted the unique request marker') +console.log(JSON.stringify({ proof: 'built-pi-tool', marker, input, response: JSON.parse(text) }, null, 2)) +await events.get('session_shutdown')?.({}, ctx) diff --git a/scripts/prove-sdk-transport.mjs b/scripts/prove-sdk-transport.mjs new file mode 100644 index 0000000..e40ea01 --- /dev/null +++ b/scripts/prove-sdk-transport.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +// Operator proof, not a unit suite. Uses real TCP sockets and the built SDK. +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { once } from 'node:events' +import { setTimeout as sleep } from 'node:timers/promises' +import { TCloudClient } from '../packages/tcloud/dist/index.js' + +async function cancellation(kind, relayed = false) { + const abort = new AbortController() + let requests = 0 + let disconnected = false + let watchdog + let abortTimer + const server = createServer((req, res) => { + requests++ + req.resume() + res.once('close', () => { disconnected = true }) + if (kind === 'retry') { + res.writeHead(503, { 'Content-Type': 'application/json' }) + res.end('{"error":{"message":"proof retry"}}') + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.write('{"choices":[') + watchdog = setTimeout(() => res.end(']}'), 3000) + } + abortTimer = setTimeout(() => abort.abort(new DOMException('operator cancelled', 'AbortError')), 30) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const base = `http://127.0.0.1:${server.address().port}` + const client = new TCloudClient({ + apiKey: 'local-proof-only', baseURL: `${base}/v1`, timeout: 0, + retry: { maxRetries: 3, initialBackoffMs: 1500 }, + ...(relayed ? { privacy: { mode: 'relayer', relayerUrl: base } } : {}), + }) + const started = Date.now() + try { + const work = kind === 'search' + ? client.search({ query: 'proof', signal: abort.signal }) + : client.chat({ messages: [{ role: 'user', content: 'proof' }], signal: abort.signal }) + await assert.rejects(work, { name: 'AbortError' }) + assert.ok(Date.now() - started < 1000, 'abort waited for the response or retry') + for (let i = 0; i < 50 && !disconnected; i++) await sleep(10) + assert.equal(disconnected, true, 'server did not see the closed request') + await sleep(100) + assert.equal(requests, 1, 'caller cancellation retried') + console.log(JSON.stringify({ proof: 'cancellation', kind, relayed, requests, disconnected, elapsedMs: Date.now() - started })) + } finally { + clearTimeout(watchdog) + clearTimeout(abortTimer) + server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) + } +} + +async function receipts() { + const server = createServer(async (req, res) => { + let body = '' + for await (const chunk of req) body += chunk + const model = JSON.parse(body).model + const headers = { 'Content-Type': 'application/json' } + if (model !== 'missing') headers['X-Tangle-Cost-USD'] = model === 'first' ? '0.01' : '0.02' + res.writeHead(200, headers) + res.end(JSON.stringify({ + id: model, model, choices: [{ message: { role: 'assistant', content: model } }], + usage: { prompt_tokens: 2, completion_tokens: 1, total_tokens: 3 }, + tangle: { costUsd: 999, costSource: 'receipt' }, + })) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const client = new TCloudClient({ apiKey: 'local-proof-only', baseURL: `http://127.0.0.1:${server.address().port}/v1` }) + try { + const results = await Promise.all(['first', 'second', 'missing'].map(model => client.chat({ model, messages: [{ role: 'user', content: 'proof' }] }))) + assert.equal(results[0].tangle.costUsd, 0.01) + assert.equal(results[1].tangle.costUsd, 0.02) + assert.equal(results[2].tangle, undefined, 'missing price was labelled as a receipt') + console.log(JSON.stringify({ proof: 'per-response-cost', receipts: results.map(r => r.tangle ?? null) })) + } finally { + server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) + } +} + +async function receiptWithoutUsage() { + let requests = 0 + const reached = [] + const server = createServer((req, res) => { + requests++ + req.resume() + res.writeHead(200, { 'Content-Type': 'application/json', 'X-Tangle-Cost-USD': '0.033' }) + res.end(JSON.stringify({ id: 'header-only', choices: [{ message: { role: 'assistant', content: 'proof' } }] })) + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const client = new TCloudClient({ + apiKey: 'local-proof-only', baseURL: `http://127.0.0.1:${server.address().port}/v1`, + limits: { maxTotalSpend: 0.03, maxCostPerRequest: 0.02, onLimitReached: event => reached.push(event) }, + }) + try { + const result = await client.chat({ messages: [{ role: 'user', content: 'proof' }] }) + console.log(JSON.stringify({ proof: 'receipt-without-usage', receipt: result.tangle ?? null, usage: client.usage })) + assert.deepEqual(result.tangle, { costUsd: 0.033, costSource: 'receipt' }) + assert.equal(client.usage.totalSpent, 0.033) + assert.deepEqual(reached, [{ type: 'cost', current: 0.033, limit: 0.02 }]) + await assert.rejects(client.chat({ messages: [{ role: 'user', content: 'blocked' }] }), { status: 429 }) + assert.equal(requests, 1, 'spend limit allowed another upstream request') + } finally { + server.closeAllConnections() + await new Promise(resolve => server.close(resolve)) + } +} + +await cancellation('chat') +await cancellation('search') +await cancellation('retry') +await cancellation('chat', true) +await receipts() +await receiptWithoutUsage()