tardie 0.12.0 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tardie",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "license": "MIT",
5
5
  "author": "Clavia, Inc.",
6
6
  "description": "A durable agent harness. State is a pure function of the log.",
@@ -16,7 +16,7 @@
16
16
  "access": "public"
17
17
  },
18
18
  "tardigrade": {
19
- "sourceTree": "08d69b6ce9e98f53871766428cc614668c943eec"
19
+ "sourceTree": "8db5a667a08511b951c0a6e341f585b76e77fb65"
20
20
  },
21
21
  "files": [
22
22
  "src",
@@ -71,19 +71,35 @@
71
71
  "./model/*": "./src/model/*.ts"
72
72
  },
73
73
  "dependencies": {
74
- "@aws-sdk/client-bedrock-runtime": "^3.1079.0",
75
74
  "@cfworker/json-schema": "^4.1.1",
76
75
  "@effect/platform-bun": "4.0.0-rc.110",
77
76
  "@effect/platform-node-shared": "4.0.0-rc.110",
78
77
  "@effect/sql-sqlite-bun": "4.0.0-rc.110",
79
78
  "@effect/sql-sqlite-do": "4.0.0-rc.110",
80
- "@smithy/fetch-http-handler": "^5.6.3",
81
- "@smithy/node-http-handler": "^4.11.2",
82
79
  "@tanstack/ai": "0.46.0",
83
80
  "@tanstack/ai-anthropic": "0.16.6",
84
- "@tanstack/ai-bedrock": "0.2.3",
85
81
  "@tanstack/ai-openai": "0.20.0",
86
82
  "effect": "4.0.0-rc.110",
87
83
  "jsonc-parser": "3.3.1"
84
+ },
85
+ "peerDependencies": {
86
+ "@aws-sdk/client-bedrock-runtime": "^3.1079.0",
87
+ "@smithy/fetch-http-handler": "^5.6.3",
88
+ "@smithy/node-http-handler": "^4.11.2",
89
+ "@tanstack/ai-bedrock": "0.2.3"
90
+ },
91
+ "peerDependenciesMeta": {
92
+ "@aws-sdk/client-bedrock-runtime": {
93
+ "optional": true
94
+ },
95
+ "@smithy/fetch-http-handler": {
96
+ "optional": true
97
+ },
98
+ "@smithy/node-http-handler": {
99
+ "optional": true
100
+ },
101
+ "@tanstack/ai-bedrock": {
102
+ "optional": true
103
+ }
88
104
  }
89
105
  }
@@ -1,5 +1,5 @@
1
1
  import { Clock, Effect } from "effect"
2
- import { effect, type Reactor } from "tardie/core/reconciliation"
2
+ import { effect, Self, type Reactor } from "tardie/core/reconciliation"
3
3
  import { compactionCompleted } from "../log/events"
4
4
  import type { Event } from "tardie/core/log/event"
5
5
  import { turnOf, turnView } from "tardie/code/execution/turns"
@@ -346,7 +346,7 @@ const firedUncovered = (log: ReadonlyArray<Event>): boolean => {
346
346
  //
347
347
  // The policy this takes must be the one the render takes, or the guard measures a request the
348
348
  // model never sees (ContextPolicy above).
349
- export const compactionReactor = (policy: Partial<CompactionPolicy> = {}): Reactor<Infer> => (log) => {
349
+ export const compactionReactor = (policy: Partial<CompactionPolicy> = {}): Reactor<Infer | Self> => (log) => {
350
350
  const model = modelResolutionOf(log).model
351
351
  const resolved = contextPolicyFrom(log, policy)
352
352
  // The projection runs first, so the guard, the cut, and the brief all read the history the
@@ -371,6 +371,7 @@ export const compactionReactor = (policy: Partial<CompactionPolicy> = {}): React
371
371
  },
372
372
  act: (input) =>
373
373
  Effect.gen(function* () {
374
+ const self = yield* Self
374
375
  const at = yield* Clock.currentTimeMillis
375
376
  const lines = input.span.map((e) => lineOf(e, resolved)).filter((l): l is string => l !== null)
376
377
  if (lines.length === 0) {
@@ -392,6 +393,7 @@ export const compactionReactor = (policy: Partial<CompactionPolicy> = {}): React
392
393
  const action = yield* (yield* Infer).react(
393
394
  {
394
395
  trajectory: [{ type: "MessageReceived", id: `compact-${input.keepFrom}`, text: brief, at }],
396
+ identity: { ...self, turn: `compact-${input.keepFrom}` },
395
397
  ...(model === undefined ? {} : { model }),
396
398
  system: "",
397
399
  tools: []
@@ -414,7 +416,7 @@ export const compactionReactor = (policy: Partial<CompactionPolicy> = {}): React
414
416
 
415
417
  // compaction derives one resolved context contribution and the transitions governed by the same
416
418
  // model-relative policy.
417
- export const compaction = (policy: Partial<CompactionPolicy> = {}): AgentComponent<Infer> => {
419
+ export const compaction = (policy: Partial<CompactionPolicy> = {}): AgentComponent<Infer | Self> => {
418
420
  const reactor = compactionReactor(policy)
419
421
  return {
420
422
  name: "compaction",
@@ -36,6 +36,13 @@ export {
36
36
  type Render
37
37
  } from "./inference/reactor"
38
38
  export { ModelRef, modelRefOf } from "./inference/reference"
39
+ export {
40
+ DEFAULT_INFERENCE_OBSERVER_POLICY,
41
+ type InferDelta,
42
+ type InferenceIdentity,
43
+ type InferenceObserver,
44
+ type InferenceObserverPolicy
45
+ } from "./inference/observer"
39
46
  export {
40
47
  applyModelPolicy,
41
48
  DEFAULT_MODEL_POLICY,
@@ -0,0 +1,37 @@
1
+ import type { Effect } from "effect"
2
+ import type { ModelRef } from "./reference"
3
+
4
+ // InferenceIdentity identifies the actor turn that opened a logical model attempt (index.test.ts, "root and child inference requests carry their actor identity").
5
+ export interface InferenceIdentity {
6
+ readonly actor: string
7
+ readonly thread: string
8
+ readonly turn: string
9
+ }
10
+
11
+ // InferDelta is ephemeral normalized text from one physical provider request. Sequence is zero-based within that request, so a consumer can detect a dropped delta (model.test.ts, "observes normalized text without changing the terminal action").
12
+ export interface InferDelta extends InferenceIdentity {
13
+ readonly logicalAttempt: string
14
+ readonly physicalAttempt: string
15
+ readonly model: ModelRef
16
+ readonly blockIndex: number
17
+ readonly sequence: number
18
+ readonly text: string
19
+ }
20
+
21
+ // InferenceObserver receives ephemeral output outside the durable log. Failure and timeout discard that delivery without changing inference (model.test.ts, "observer failure and saturation leave inference unchanged").
22
+ export interface InferenceObserver {
23
+ readonly onDelta: (delta: InferDelta) => Effect.Effect<void, Error>
24
+ readonly policy?: Partial<InferenceObserverPolicy>
25
+ }
26
+
27
+ // InferenceObserverPolicy bounds pending deliveries and each observer call.
28
+ export interface InferenceObserverPolicy {
29
+ readonly bufferCapacity: number
30
+ readonly deliveryTimeoutMs: number
31
+ }
32
+
33
+ // DEFAULT_INFERENCE_OBSERVER_POLICY bounds best-effort delivery while each observer may override both fields.
34
+ export const DEFAULT_INFERENCE_OBSERVER_POLICY: InferenceObserverPolicy = {
35
+ bufferCapacity: 64,
36
+ deliveryTimeoutMs: 100
37
+ }
@@ -1,6 +1,6 @@
1
1
  import { Cause, Clock, Context, Effect } from "effect"
2
2
  import { EventLog } from "tardie/core/log"
3
- import { intent, effect, type Reactor } from "tardie/core/reconciliation"
3
+ import { intent, effect, Self, type Reactor } from "tardie/core/reconciliation"
4
4
  import { modelCalled, modelResolved, outputRejected, textReturned, turnFailed } from "../log/events"
5
5
  import type { Event } from "tardie/core/log/event"
6
6
  import type { Action } from "../log/events"
@@ -28,6 +28,7 @@ import {
28
28
  type ModelPolicy,
29
29
  type ModelPolicyOverride
30
30
  } from "./access"
31
+ import type { InferenceIdentity } from "./observer"
31
32
 
32
33
  // The infer reactor: the model loop, and nothing else. A think is owed when the current turn
33
34
  // has no unanswered tool call and no terminal; serving marks the attempt, does inference, then
@@ -51,6 +52,7 @@ export const DEFAULT_INFER_POLICY: InferPolicy = { giveUpAfter: 3, models: DEFAU
51
52
  // about tools; the actor is the render's one owner.
52
53
  export interface InferRequest {
53
54
  readonly trajectory: ReadonlyArray<Event>
55
+ readonly identity: InferenceIdentity
54
56
  readonly model?: ModelRef
55
57
  readonly system: string
56
58
  readonly tools: ReadonlyArray<import("./request").ToolSpec>
@@ -318,7 +320,7 @@ export type Render = (log: ReadonlyArray<Event>) => {
318
320
  readonly output?: { readonly fallback: OutputFallback; readonly system?: string }
319
321
  }
320
322
 
321
- export const inferReactorFor = (policy: Partial<InferPolicy>, render: Render): Reactor<Infer | EventLog> => (log) => {
323
+ export const inferReactorFor = (policy: Partial<InferPolicy>, render: Render): Reactor<Infer | EventLog | Self> => (log) => {
322
324
  const giveUpAfter = policy.giveUpAfter ?? DEFAULT_INFER_POLICY.giveUpAfter
323
325
  const slice = turnView(log)
324
326
  if (slice.length === 0 || awaitingTool(slice) || terminated(slice)) return []
@@ -514,6 +516,7 @@ export const inferReactorFor = (policy: Partial<InferPolicy>, render: Render): R
514
516
  act: (input) =>
515
517
  Effect.gen(function* () {
516
518
  const events = yield* EventLog
519
+ const self = yield* Self
517
520
  const at = yield* Clock.currentTimeMillis
518
521
  // The mark records the attempt BEFORE the inference, appended by the act itself: a
519
522
  // died attempt leaves its mark, the next derivation counts it, the bound holds.
@@ -531,7 +534,12 @@ export const inferReactorFor = (policy: Partial<InferPolicy>, render: Render): R
531
534
  })
532
535
  ])
533
536
  const action = yield* (yield* Infer)
534
- .react({ trajectory: input.trajectory, ...(input.model === undefined ? {} : { model: input.model }), ...input.render }, input.attempt)
537
+ .react({
538
+ trajectory: input.trajectory,
539
+ identity: { ...self, turn: input.turn },
540
+ ...(input.model === undefined ? {} : { model: input.model }),
541
+ ...input.render
542
+ }, input.attempt)
535
543
  .pipe(
536
544
  Effect.catchCause((cause) =>
537
545
  Cause.hasInterruptsOnly(cause)
@@ -415,7 +415,8 @@ export const initCommand = Command.make("init", {
415
415
  const initialized = yield* Effect.tryPromise({
416
416
  try: () => initActor(name, {
417
417
  cwd: cli.cwd,
418
- ...(directory === undefined ? {} : { directory })
418
+ ...(directory === undefined ? {} : { directory }),
419
+ modelProtocol: answers.protocol
419
420
  }),
420
421
  catch: userErrorOf
421
422
  })
package/src/cli/init.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdir, rm, writeFile } from "node:fs/promises"
2
2
  import { relative, resolve } from "node:path"
3
3
  import { DEFAULT_PROJECT_CONFIG_PATH } from "tardie/server/config"
4
+ import type { ModelProtocol } from "tardie/model/directory"
4
5
 
5
6
  import { CELLD_PROJECT_CONFIG_PATH, celldConfigOf } from "./celld"
6
7
  import { actorTemplate } from "./template"
@@ -18,6 +19,7 @@ export interface InitActorOptions {
18
19
  readonly directory?: string
19
20
  readonly now?: Date
20
21
  readonly packageVersion?: string
22
+ readonly modelProtocol?: ModelProtocol
21
23
  }
22
24
 
23
25
  export interface InitializedActor {
@@ -57,12 +59,31 @@ const manifestTemplate = (name: string, now: Date): string => `${JSON.stringify(
57
59
  }
58
60
  }, undefined, 2)}\n`
59
61
 
60
- const workerTemplate = `import definition from "./actor"
62
+ const adapterFor = (protocol: ModelProtocol): { readonly name: string; readonly source: string } => {
63
+ switch (protocol) {
64
+ case "anthropic-messages":
65
+ return { name: "anthropicAdapter", source: "tardie/model/anthropic" }
66
+ case "bedrock-converse":
67
+ return { name: "bedrockAdapter", source: "tardie/model/bedrock" }
68
+ case "openai-responses":
69
+ case "openai-chat-completions":
70
+ return { name: "openAICompatibleAdapter", source: "tardie/model/openai" }
71
+ }
72
+ }
73
+
74
+ const workerTemplate = (protocol: ModelProtocol): string => {
75
+ const adapter = adapterFor(protocol)
76
+ return `import definition from "./actor"
61
77
  import { ActorHost, cloudflareWorker } from "tardie/cloudflare"
78
+ import { modelAdapters } from "tardie/model/adapter"
79
+ import { ${adapter.name} } from "${adapter.source}"
62
80
 
63
81
  export { ActorHost }
64
- export default cloudflareWorker(definition)
82
+ export default cloudflareWorker(definition, {
83
+ modelAdapters: modelAdapters(${adapter.name})
84
+ })
65
85
  `
86
+ }
66
87
 
67
88
  const packageTemplate = (version: string): string => `${JSON.stringify({
68
89
  private: true,
@@ -88,7 +109,7 @@ export const initActor = async (name: string, options: InitActorOptions): Promis
88
109
 
89
110
  try {
90
111
  await writeFile(entry, source, "utf8")
91
- await writeFile(worker, workerTemplate, "utf8")
112
+ await writeFile(worker, workerTemplate(options.modelProtocol ?? "openai-chat-completions"), "utf8")
92
113
  await writeFile(manifest, manifestSource, "utf8")
93
114
  await writeFile(celldManifest, celldConfigOf(manifestSource, manifest).source, "utf8")
94
115
  await writeFile(packageManifest, packageTemplate(packageVersion), "utf8")
@@ -1,7 +1,7 @@
1
1
  import { DurableObject } from "cloudflare:workers"
2
2
  import { Clock, Context, Effect, Layer, Schema } from "effect"
3
3
  import { FetchHttpClient, HttpClient, HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
4
- import { actor, agentMethods, agentsPackage, applyModelPolicy, budget, codeMode, compaction, fetchPackage, Infer, infer as inferAgent, intersectModelPolicies, modelAllowedBy, outputValidateOnce, workspacePackage, type Actor, type ActorMethods, type ModelPolicy, type ModelRef } from "tardie"
4
+ import { actor, agentMethods, agentsPackage, applyModelPolicy, budget, codeMode, compaction, fetchPackage, Infer, infer as inferAgent, intersectModelPolicies, modelAllowedBy, outputValidateOnce, workspacePackage, type Actor, type ActorMethods, type InferenceObserver, type ModelPolicy, type ModelRef } from "tardie"
5
5
  import type { Action } from "tardie/log/events"
6
6
  import {
7
7
  CATALOG_AVAILABILITY_FILTERS,
@@ -10,6 +10,10 @@ import {
10
10
  MODEL_CATALOG_UNPRICED_ORDERS
11
11
  } from "tardie/client/contract"
12
12
  import { infer } from "tardie/model/model"
13
+ import {
14
+ modelAdapters,
15
+ type ModelAdapterRegistry
16
+ } from "tardie/model/adapter"
13
17
  import { DEFAULT_MODEL_CATALOG_URL } from "tardie/model/metadata"
14
18
  import {
15
19
  loadModelCatalog,
@@ -79,6 +83,8 @@ interface MountedActor {
79
83
  readonly name: string
80
84
  readonly actor: DefaultAssembly
81
85
  readonly methods: ActorMethods
86
+ readonly modelAdapters: ModelAdapterRegistry
87
+ readonly inferenceObserverFor?: (context: CloudflareWorkerLayerContext<Env>) => InferenceObserver
82
88
  readonly layersFor?: (context: CloudflareWorkerLayerContext<Env>) => CloudflareLaneEnv<never>
83
89
  }
84
90
 
@@ -182,7 +188,12 @@ const selectedModelFrom = (
182
188
  return { reference: selected, provider, metadata: model.metadata, contextWindowTokens, catalogRevision: catalog.snapshot.revision }
183
189
  }
184
190
 
185
- const modelLayer = (models: CloudflareModels | undefined, catalog: ModelCatalogState) => {
191
+ const modelLayer = (
192
+ models: CloudflareModels | undefined,
193
+ catalog: ModelCatalogState,
194
+ adapters: ModelAdapterRegistry,
195
+ observer?: InferenceObserver
196
+ ) => {
186
197
  if (models === undefined) {
187
198
  const failed: Action = { kind: "fail", error: "no model is configured", failure: { cause: "inference_error", attempts: 1 } }
188
199
  return Layer.succeed(Infer)({
@@ -226,7 +237,7 @@ const modelLayer = (models: CloudflareModels | undefined, catalog: ModelCatalogS
226
237
  contextWindowTokens: selectedModel.contextWindowTokens,
227
238
  ...(selectedModel.metadata.maxOutputTokens === undefined ? {} : { maxOutputTokens: selectedModel.metadata.maxOutputTokens }),
228
239
  ...(selectedModel.metadata.pricing === undefined ? {} : { pricing: selectedModel.metadata.pricing })
229
- })
240
+ }, adapters, observer === undefined ? {} : { observer })
230
241
  return Effect.flatMap(Infer, (model) => model.react(request, key)).pipe(Effect.provide(selected))
231
242
  }
232
243
  })
@@ -396,6 +407,8 @@ export class ActorHost extends DurableObject<Env> {
396
407
 
397
408
  private async openHost(): Promise<CloudflareHost> {
398
409
  const models = modelsFrom(this.env)
410
+ const adapters = mountedActor?.modelAdapters ?? modelAdapters()
411
+ for (const provider of Object.values(models?.providers ?? {})) adapters.resolve(provider.protocol)
399
412
  const catalog: ModelCatalogState = models === undefined
400
413
  ? { refreshError: "no model is configured" }
401
414
  : await this.catalog()
@@ -462,7 +475,8 @@ export class ActorHost extends DurableObject<Env> {
462
475
  principal,
463
476
  actorFor: (lane) => threadOf(lane) === undefined ? undefined : selectedAssembly,
464
477
  layersFor: (lane) => {
465
- const framework = Layer.mergeAll(modelLayer(models, catalog), FetchHttpClient.layer, sandboxLayer)
478
+ const observer = mountedActor?.inferenceObserverFor?.({ env: this.env, lane })
479
+ const framework = Layer.mergeAll(modelLayer(models, catalog, adapters, observer), FetchHttpClient.layer, sandboxLayer)
466
480
  const application = mountedActor?.layersFor?.({ env: this.env, lane })
467
481
  return application === undefined ? framework : Layer.mergeAll(framework, application)
468
482
  },
@@ -846,8 +860,16 @@ type CloudflareWorkerLayersFor<R, WorkerEnv extends Env> = (
846
860
  // CloudflareWorkerOptions supplies every actor requirement the Worker does not bind itself.
847
861
  export type CloudflareWorkerOptions<R, WorkerEnv extends Env = Env> =
848
862
  [CloudflareApplicationRequirements<R>] extends [never]
849
- ? { readonly layersFor?: CloudflareWorkerLayersFor<R, WorkerEnv> }
850
- : { readonly layersFor: CloudflareWorkerLayersFor<R, WorkerEnv> }
863
+ ? {
864
+ readonly layersFor?: CloudflareWorkerLayersFor<R, WorkerEnv>
865
+ readonly modelAdapters?: ModelAdapterRegistry
866
+ readonly inferenceObserverFor?: (context: CloudflareWorkerLayerContext<WorkerEnv>) => InferenceObserver
867
+ }
868
+ : {
869
+ readonly layersFor: CloudflareWorkerLayersFor<R, WorkerEnv>
870
+ readonly modelAdapters?: ModelAdapterRegistry
871
+ readonly inferenceObserverFor?: (context: CloudflareWorkerLayerContext<WorkerEnv>) => InferenceObserver
872
+ }
851
873
 
852
874
  type CloudflareWorkerArguments<R, WorkerEnv extends Env> =
853
875
  [CloudflareApplicationRequirements<R>] extends [never]
@@ -867,6 +889,10 @@ export const cloudflareWorker = <
867
889
  name: definition.name,
868
890
  actor: definition as unknown as DefaultAssembly,
869
891
  methods: definition.methods,
892
+ modelAdapters: options?.modelAdapters ?? modelAdapters(),
893
+ ...(options?.inferenceObserverFor === undefined ? {} : {
894
+ inferenceObserverFor: options.inferenceObserverFor as unknown as (context: CloudflareWorkerLayerContext<Env>) => InferenceObserver
895
+ }),
870
896
  ...(options?.layersFor === undefined ? {} : {
871
897
  layersFor: options.layersFor as unknown as (context: CloudflareWorkerLayerContext<Env>) => CloudflareLaneEnv<never>
872
898
  })
@@ -0,0 +1,104 @@
1
+ import type { ModelMessage, StreamChunk, Tool } from "@tanstack/ai"
2
+ import type { ModelRequest } from "tardie/inference/request"
3
+ import type { OutputMode } from "tardie/output/contract"
4
+ import type { ModelPricing } from "tardie/inference/usage"
5
+ import type { ModelProtocol } from "./directory"
6
+ import type { OutputCapability } from "./output"
7
+
8
+ export interface StreamBounds {
9
+ readonly firstChunkMs: number
10
+ readonly idleMs: number
11
+ readonly totalMs: number
12
+ }
13
+
14
+ export type ModelFetch = (
15
+ input: Parameters<typeof globalThis.fetch>[0],
16
+ init?: Parameters<typeof globalThis.fetch>[1]
17
+ ) => Promise<Response>
18
+
19
+ export interface ModelConfig {
20
+ readonly baseUrl: string
21
+ readonly apiKey: string
22
+ readonly model: string
23
+ readonly protocol: ModelProtocol
24
+ readonly provider: string
25
+ // region selects the AWS region for a Bedrock Converse connection.
26
+ readonly region?: string
27
+ readonly contextWindowTokens: number
28
+ // maxOutputTokens caps every truncation-ladder rung; an omitted value uses the exported ladder.
29
+ readonly maxOutputTokens?: number
30
+ // maxTokensLadder replaces the exported truncation ladder before maxOutputTokens bounds it.
31
+ readonly maxTokensLadder?: ReadonlyArray<number>
32
+ // stream replaces any stated fields in DEFAULT_STREAM_BOUNDS.
33
+ readonly stream?: Partial<StreamBounds>
34
+ // output states the endpoint guarantee; an omitted value promises no native contract support.
35
+ readonly output?: OutputCapability
36
+ // pricing supplies the estimate used when the provider reports tokens without a billed cost.
37
+ readonly pricing?: ModelPricing
38
+ // throttleRetryDelaysMs sets the backoff bases and its length sets the retry count.
39
+ readonly throttleRetryDelaysMs?: ReadonlyArray<number>
40
+ // retryAfterJitterMs adds a random wait to a provider Retry-After value.
41
+ readonly retryAfterJitterMs?: number
42
+ // fetch replaces the transport for an embedding or test.
43
+ readonly fetch?: ModelFetch
44
+ // sleep replaces the retry wait for an embedding or test.
45
+ readonly sleep?: (ms: number) => Promise<void>
46
+ }
47
+
48
+ export type ModelStopClass = "refused" | "truncated" | "violation" | "ok"
49
+
50
+ export interface ModelAdapterContext {
51
+ readonly config: ModelConfig
52
+ readonly request: ModelRequest
53
+ readonly mode: OutputMode
54
+ readonly maxTokens: number
55
+ readonly bounds: StreamBounds
56
+ readonly fetch: ModelFetch
57
+ readonly messages: ReadonlyArray<ModelMessage>
58
+ readonly tools: ReadonlyArray<Tool>
59
+ readonly systemPrompts: ReadonlyArray<string>
60
+ }
61
+
62
+ export interface ModelAdapterAttempt {
63
+ readonly stream: AsyncIterable<StreamChunk>
64
+ readonly reportedUsage?: () => unknown
65
+ readonly stopClass?: () => ModelStopClass
66
+ readonly finishReason?: () => string | undefined
67
+ }
68
+
69
+ export interface ModelAdapter {
70
+ readonly id: string
71
+ // ModelAdapter protocols select wire implementations independently of provider and model identity (adapter.test.ts, "resolves each protocol to its registered implementation").
72
+ readonly protocols: ReadonlyArray<ModelProtocol>
73
+ readonly start: (context: ModelAdapterContext) => ModelAdapterAttempt
74
+ }
75
+
76
+ export interface ModelAdapterRegistry {
77
+ readonly protocols: ReadonlyArray<ModelProtocol>
78
+ readonly resolve: (protocol: ModelProtocol) => ModelAdapter
79
+ }
80
+
81
+ // modelAdapters constructs an immutable protocol registry and rejects ambiguous implementations.
82
+ export const modelAdapters = (...adapters: ReadonlyArray<ModelAdapter>): ModelAdapterRegistry => {
83
+ const byProtocol = new Map<ModelProtocol, ModelAdapter>()
84
+ for (const adapter of adapters) {
85
+ for (const protocol of adapter.protocols) {
86
+ const previous = byProtocol.get(protocol)
87
+ if (previous !== undefined) {
88
+ throw new Error(`model protocol ${JSON.stringify(protocol)} has adapters ${JSON.stringify(previous.id)} and ${JSON.stringify(adapter.id)}`)
89
+ }
90
+ byProtocol.set(protocol, adapter)
91
+ }
92
+ }
93
+ const protocols = Object.freeze([...byProtocol.keys()])
94
+ return Object.freeze({
95
+ protocols,
96
+ resolve: (protocol: ModelProtocol): ModelAdapter => {
97
+ const adapter = byProtocol.get(protocol)
98
+ if (adapter !== undefined) return adapter
99
+ throw new Error(
100
+ `model protocol ${JSON.stringify(protocol)} has no registered adapter; register one with modelAdapters(...) before starting the host`
101
+ )
102
+ }
103
+ })
104
+ }
@@ -0,0 +1,29 @@
1
+ import { createAnthropicChat } from "@tanstack/ai-anthropic"
2
+ import { outputSchemaFor } from "./output"
3
+ import type { ModelAdapter } from "./adapter"
4
+
5
+ // anthropicAdapter binds the Anthropic Messages protocol through TanStack AI.
6
+ export const anthropicAdapter: ModelAdapter = {
7
+ id: "tanstack/anthropic",
8
+ protocols: ["anthropic-messages"],
9
+ start: ({ config, request, mode, fetch, messages, tools, systemPrompts }) => {
10
+ const outputSchema = request.output?.kind === "contract" && mode.kind === "native"
11
+ ? outputSchemaFor(request.output, mode)
12
+ : undefined
13
+ const adapter = createAnthropicChat(config.model as never, config.apiKey, {
14
+ baseURL: config.baseUrl,
15
+ maxRetries: 0,
16
+ fetch
17
+ })
18
+ return {
19
+ stream: adapter.chatStream({
20
+ model: config.model,
21
+ messages: messages as never,
22
+ tools: tools as never,
23
+ systemPrompts,
24
+ ...(outputSchema === undefined ? {} : { outputSchema }),
25
+ logger: new Proxy({}, { get: () => () => {} }) as never
26
+ } as never)
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,158 @@
1
+ import * as BedrockRuntime from "@aws-sdk/client-bedrock-runtime"
2
+ import { FetchHttpHandler } from "@smithy/fetch-http-handler"
3
+ import { BedrockConverseTextAdapter, type BEDROCK_CONVERSE_MODELS } from "@tanstack/ai-bedrock"
4
+ import type { OutputRequest } from "tardie/inference/request"
5
+ import { NATIVE_MODE, type OutputMode } from "tardie/output/contract"
6
+ import { outputNameFor, outputSchemaFor } from "./output"
7
+ import type { ModelAdapter, ModelConfig, ModelStopClass, StreamBounds } from "./adapter"
8
+
9
+ type SmithyHandler = Pick<FetchHttpHandler, "handle" | "destroy">
10
+
11
+ const bedrockHandler = (config: ModelConfig, bounds: StreamBounds): SmithyHandler => {
12
+ const transport: Promise<SmithyHandler> =
13
+ (globalThis as { Bun?: unknown }).Bun === undefined
14
+ ? Promise.resolve(new FetchHttpHandler({ requestTimeout: bounds.totalMs }))
15
+ : (() => {
16
+ const moduleName = "@smithy/node-http-handler"
17
+ return (import(/* @vite-ignore */ moduleName) as Promise<typeof import("@smithy/node-http-handler")>).then(
18
+ ({ NodeHttpHandler: Handler }) =>
19
+ new Handler({
20
+ connectionTimeout: bounds.firstChunkMs,
21
+ socketTimeout: bounds.idleMs,
22
+ requestTimeout: bounds.totalMs,
23
+ throwOnRequestTimeout: true
24
+ })
25
+ )
26
+ })()
27
+
28
+ return {
29
+ handle: async (request, handlerOptions) => {
30
+ request.headers = Object.fromEntries(
31
+ Object.entries(request.headers).filter(([key]) => key.toLowerCase() !== "authorization")
32
+ )
33
+ request.headers["cf-aig-authorization"] = `Bearer ${config.apiKey}`
34
+ return (await transport).handle(request, handlerOptions)
35
+ },
36
+ destroy: () => {
37
+ void transport.then((handler) => handler.destroy()).catch(() => undefined)
38
+ }
39
+ }
40
+ }
41
+
42
+ export const converseOutputConfig = (
43
+ output: OutputRequest,
44
+ mode: OutputMode
45
+ ): BedrockRuntime.OutputConfig | undefined => {
46
+ const schema = outputSchemaFor(output, mode)
47
+ const name = outputNameFor(output, mode)
48
+ if (schema === undefined || name === undefined) return undefined
49
+ return { textFormat: { type: "json_schema", structure: { jsonSchema: { name, schema: JSON.stringify(schema) } } } }
50
+ }
51
+
52
+ export const converseStopClass = (stopReason: string | undefined): ModelStopClass => {
53
+ switch (stopReason) {
54
+ case "guardrail_intervened":
55
+ case "content_filtered":
56
+ return "refused"
57
+ case "max_tokens":
58
+ case "model_context_window_exceeded":
59
+ return "truncated"
60
+ case "malformed_model_output":
61
+ return "violation"
62
+ default:
63
+ return "ok"
64
+ }
65
+ }
66
+
67
+ export const tapStopReason = <T>(
68
+ stream: AsyncIterable<T>,
69
+ into: { stopReason?: string }
70
+ ): AsyncIterable<T> => ({
71
+ async *[Symbol.asyncIterator]() {
72
+ for await (const event of stream) {
73
+ const stop = (event as { messageStop?: { stopReason?: unknown } }).messageStop?.stopReason
74
+ if (typeof stop === "string") into.stopReason = stop
75
+ yield event
76
+ }
77
+ }
78
+ })
79
+
80
+ export const tapConverseUsage = <T>(
81
+ stream: AsyncIterable<T>,
82
+ into: { usage?: unknown }
83
+ ): AsyncIterable<T> => ({
84
+ async *[Symbol.asyncIterator]() {
85
+ for await (const event of stream) {
86
+ const usage = (event as { metadata?: { usage?: unknown } }).metadata?.usage
87
+ if (usage !== undefined) into.usage = usage
88
+ yield event
89
+ }
90
+ }
91
+ })
92
+
93
+ export const bedrockConverseTextAdapter = (
94
+ config: ModelConfig,
95
+ maxTokens: number,
96
+ bounds: StreamBounds,
97
+ output?: OutputRequest,
98
+ mode: OutputMode = NATIVE_MODE,
99
+ stops: { stopReason?: string } = {},
100
+ reported: { usage?: unknown } = {}
101
+ ) => {
102
+ const handler = bedrockHandler(config, bounds)
103
+ const region = config.region ?? config.baseUrl.split("/").filter((s) => s !== "").at(-1)
104
+ if (region === undefined) throw new Error("a Bedrock connection must declare its AWS region")
105
+ return new (class extends BedrockConverseTextAdapter<(typeof BEDROCK_CONVERSE_MODELS)[number]> {
106
+ protected override importBedrockRuntime(): Promise<typeof BedrockRuntime> {
107
+ return Promise.resolve(BedrockRuntime)
108
+ }
109
+ protected override buildClientConfig(
110
+ resolved: Parameters<BedrockConverseTextAdapter<(typeof BEDROCK_CONVERSE_MODELS)[number]>["buildClientConfig"]>[0],
111
+ resolvedRegion: string,
112
+ endpoint: string | undefined
113
+ ) {
114
+ return { ...super.buildClientConfig(resolved, resolvedRegion, endpoint), requestHandler: handler, maxAttempts: 1 }
115
+ }
116
+ public override buildInput(options: Parameters<BedrockConverseTextAdapter<(typeof BEDROCK_CONVERSE_MODELS)[number]>["buildInput"]>[0]) {
117
+ const input = super.buildInput(options) as BedrockRuntime.ConverseStreamCommandInput
118
+ input.inferenceConfig = { ...input.inferenceConfig, maxTokens }
119
+ const outputConfig = output === undefined ? undefined : converseOutputConfig(output, mode)
120
+ if (outputConfig !== undefined) input.outputConfig = { ...input.outputConfig, ...outputConfig }
121
+ return input
122
+ }
123
+ protected override async sendStream(input: BedrockRuntime.ConverseStreamCommandInput) {
124
+ const stream = await super.sendStream(input)
125
+ return tapStopReason(tapConverseUsage(stream, reported), stops)
126
+ }
127
+ })({ apiKey: "byok", region, baseURL: config.baseUrl }, config.model as (typeof BEDROCK_CONVERSE_MODELS)[number])
128
+ }
129
+
130
+ // bedrockAdapter binds the Bedrock Converse protocol through the AWS and TanStack adapters.
131
+ export const bedrockAdapter: ModelAdapter = {
132
+ id: "tanstack/bedrock-converse",
133
+ protocols: ["bedrock-converse"],
134
+ start: ({ config, request, mode, maxTokens, bounds, messages, tools, systemPrompts }) => {
135
+ const stops: { stopReason?: string } = {}
136
+ const reported: { usage?: unknown } = {}
137
+ const adapter = bedrockConverseTextAdapter(config, maxTokens, bounds, request.output, mode, stops, reported)
138
+ return {
139
+ stream: adapter.chatStream({
140
+ model: config.model,
141
+ messages: messages as never,
142
+ tools: tools as never,
143
+ systemPrompts,
144
+ modelOptions: { max_tokens: maxTokens } as never,
145
+ logger: new Proxy({}, { get: () => () => {} }) as never
146
+ } as never),
147
+ reportedUsage: () => reported.usage,
148
+ stopClass: () => converseStopClass(stops.stopReason),
149
+ finishReason: () => stops.stopReason
150
+ }
151
+ }
152
+ }
153
+
154
+ // bedrockAdapterForBun verifies the Bun transport dependency before the host accepts Bedrock configuration.
155
+ export const bedrockAdapterForBun = async (): Promise<ModelAdapter> => {
156
+ await import("@smithy/node-http-handler")
157
+ return bedrockAdapter
158
+ }