llm: LlmCallConfig + callConfigEquals + deepFreeze — the stateless request vocabulary
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.
This commit is contained in:
@@ -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).
|
||||
|
||||
65
packages/llm/llm/src/call-config.ts
Normal file
65
packages/llm/llm/src/call-config.ts
Normal file
@@ -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<T>(value: T): T {
|
||||
const seen = new WeakSet<object>()
|
||||
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<string, unknown>)[key])
|
||||
}
|
||||
}
|
||||
walk(value)
|
||||
return value
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
44
packages/llm/llm/tests/call-config.spec.ts
Normal file
44
packages/llm/llm/tests/call-config.spec.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user