From 3f09159ab812af8d0b19b9117e45fd4eb7e84461 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:27:54 +0800 Subject: [PATCH] =?UTF-8?q?llm:=20LlmCallConfig=20+=20callConfigEquals=20+?= =?UTF-8?q?=20deepFreeze=20=E2=80=94=20the=20stateless=20request=20vocabul?= =?UTF-8?q?ary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The call configuration (model + sampling scalars) becomes named vocabulary: per-conversation state that the session log records as part of the request header (the reconstructability RFC on this branch), with callConfigEquals as the real-change detector behind logged header deltas and deepFreeze as the ownership helper the loop applies to every built request. dsh-llm stays stateless — request in, chunks out; no conversation object lives here. --- packages/llm/llm/README.md | 4 ++ packages/llm/llm/src/call-config.ts | 65 ++++++++++++++++++++++ packages/llm/llm/src/index.ts | 2 + packages/llm/llm/tests/call-config.spec.ts | 44 +++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 packages/llm/llm/src/call-config.ts create mode 100644 packages/llm/llm/tests/call-config.spec.ts diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index ab5fab49be..42694ba5ea 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -29,6 +29,10 @@ Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, ` Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. +### Call configuration (`call-config.ts`) + +`LlmCallConfig` is the model + sampling scalars of one conversation's requests (`model`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement and the loop logs a real change. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). + ### App attribution (`attribution.ts`) Every product adapter must identify the application on every provider HTTP request - attribution is part of the adapter contract, not an adapter-local nicety. `attributionHeaders(identity?)` builds the standard `User-Agent` header (`product/version (+url)`, from `userAgent()`) for every request. The default `APP_IDENTITY` carries only static public product facts (its version is read from this package's manifest); a white-label deployment passes its own `AppIdentity`, and omission falls back to the default - nothing can suppress attribution. OpenRouter-specific app attribution headers are intentionally not supported by this contract. An adapter proves compliance with a wire-level test: a mock server asserting the received header (or, for a library-backed adapter, that the library's header hook delivers the same value). Policy and rationale: [Mandatory `User-Agent` attribution](../../../docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). diff --git a/packages/llm/llm/src/call-config.ts b/packages/llm/llm/src/call-config.ts new file mode 100644 index 0000000000..0c0f705d2b --- /dev/null +++ b/packages/llm/llm/src/call-config.ts @@ -0,0 +1,65 @@ +/** + * The call configuration of a conversation and its comparison/freeze + * utilities. `LlmCallConfig` is the non-content third of the request header + * (see `EpochHeader` in dsh-session): everything about a request besides its + * message content that can undermine provider KV-cache reuse — `model` + * selects the cache namespace outright, and the sampling scalars are treated + * the same way out of caution. It is per-conversation state recorded in the + * session log (the reconstructability RFC), never a silently-drifting + * per-call knob: the `agent/request` waterfall proposes a replacement, and + * the loop logs a real change as a `request/header-delta` event. + * + * @module dsh-llm/call-config + */ + +/** + * Model + sampling scalars of one conversation's requests. Every field maps + * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests + * from the logged header rather than accepting these per call. + */ +export interface LlmCallConfig { + model: string + temperature?: number + maxTokens?: number + stop?: string[] +} + +/** + * Field-wise equality over {@link LlmCallConfig} — the comparison a caller + * runs to decide whether a proposed configuration is a real change (worth a + * logged header delta) or the held one restated. + * @param a - one configuration. + * @param b - the other. + * @returns whether every field (including the `stop` list, element-wise) matches. + */ +export function callConfigEquals(a: LlmCallConfig, b: LlmCallConfig): boolean { + if (a.model !== b.model || a.temperature !== b.temperature || a.maxTokens !== b.maxTokens) return false + if (a.stop === undefined || b.stop === undefined) return a.stop === b.stop + return a.stop.length === b.stop.length && a.stop.every((s, i) => s === b.stop?.[i]) +} + +/** + * Deep-freeze a value in place so any later mutation throws (ESM code runs in + * strict mode), and return it. The loop freezes every request it builds + * before dispatch — `llm/stream` listeners and adapters read the request, + * never rewrite it, so the wire bytes cannot silently desync from what the + * session log reconstructs. Guards against cycles with a WeakSet: loop-built + * requests hold `structuredClone`d JSON-validated session data, but the + * helper accepts arbitrarily constructed values. + * @param value - the value to freeze in place. + * @returns the same value, frozen. + */ +export function deepFreeze(value: T): T { + const seen = new WeakSet() + const walk = (node: unknown): void => { + if (node === null || typeof node !== 'object') return + if (seen.has(node)) return + seen.add(node) + Object.freeze(node) + for (const key of Object.keys(node)) { + walk((node as Record)[key]) + } + } + walk(value) + return value +} diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 220bb60062..a55722c53c 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -16,6 +16,8 @@ export * from './never.ts' export * from './error.ts' export * from './types.ts' export { BlockAssembler } from './assembler.ts' +export { callConfigEquals, deepFreeze } from './call-config.ts' +export type { LlmCallConfig } from './call-config.ts' declare module 'cordis' { interface Context { diff --git a/packages/llm/llm/tests/call-config.spec.ts b/packages/llm/llm/tests/call-config.spec.ts new file mode 100644 index 0000000000..d5648a2262 --- /dev/null +++ b/packages/llm/llm/tests/call-config.spec.ts @@ -0,0 +1,44 @@ +/** + * call-config unit tests: field-wise LlmCallConfig equality (the real-change + * detector behind logged header deltas) and the deepFreeze ownership helper + * the loop applies to every built request. + */ + +import { describe, expect, it } from 'vitest' +import { callConfigEquals, deepFreeze } from '../src/call-config.ts' + +describe('callConfigEquals', () => { + it('compares every field, including the stop list element-wise', () => { + expect(callConfigEquals({ model: 'm' }, { model: 'm' })).toBe(true) + expect(callConfigEquals({ model: 'm' }, { model: 'x' })).toBe(false) + expect(callConfigEquals({ model: 'm', temperature: 0.5 }, { model: 'm' })).toBe(false) + expect(callConfigEquals({ model: 'm', maxTokens: 1 }, { model: 'm', maxTokens: 2 })).toBe(false) + expect(callConfigEquals({ model: 'm', stop: ['a'] }, { model: 'm' })).toBe(false) + expect(callConfigEquals({ model: 'm', stop: ['a'] }, { model: 'm', stop: ['a', 'b'] })).toBe(false) + expect(callConfigEquals({ model: 'm', stop: ['a'] }, { model: 'm', stop: ['b'] })).toBe(false) + expect(callConfigEquals({ model: 'm', stop: ['a', 'b'] }, { model: 'm', stop: ['a', 'b'] })).toBe(true) + }) +}) + +describe('deepFreeze', () => { + it('freezes nested structure in place and returns the same reference', () => { + const value = { a: { b: [1, { c: 'x' }] } } + const frozen = deepFreeze(value) + expect(frozen).toBe(value) + expect(Object.isFrozen(value)).toBe(true) + expect(Object.isFrozen(value.a)).toBe(true) + expect(Object.isFrozen(value.a.b)).toBe(true) + expect(Object.isFrozen(value.a.b[1])).toBe(true) + // ESM runs in strict mode: mutation throws rather than silently failing. + expect(() => { (value.a.b[1] as { c: string }).c = 'y' }).toThrow(TypeError) + }) + + it('passes primitives through and terminates on cycles', () => { + expect(deepFreeze(42)).toBe(42) + expect(deepFreeze(null)).toBeNull() + const cyclic = { self: undefined as unknown } + cyclic.self = cyclic + deepFreeze(cyclic) + expect(Object.isFrozen(cyclic)).toBe(true) + }) +})