Browse Source

fix(llm-pi-ai): validate replay provenance

Yichen Jiang 3 tháng trước cách đây
mục cha
commit
634840d406

+ 1 - 1
packages/llm/llm-pi-ai/README.md

@@ -36,7 +36,7 @@ The selected pi-ai catalog descriptor supplies the protocol implementation. This
 
 Successful assistant responses store a versioned, lossless-JSON replay state beside their durable provider/model provenance. At request time, `LlmService` passes replay state only when the historical provider route and target provider route are currently owned by this same `PiAiAdapter` instance. The adapter validates the state and restores pi-ai response ids and provider signatures even when the target provider or model changes; pi-ai then decides which metadata its target API can reuse. History without replay state is translated as foreign provider-neutral content and never impersonates a native pi-ai response.
 
-If a listener rewrites assembled assistant content, the loop drops replay state before logging the message because its provider metadata no longer describes the content. Invalid versions, malformed metadata, and content/block mismatches fail explicitly with `LlmError('INVALID_REPLAY_STATE')`.
+If a listener rewrites assembled assistant content, the loop drops replay state before logging the message because its provider metadata no longer describes the content. Invalid versions, malformed metadata, provenance provider/model mismatches, and content/block mismatches fail explicitly with `LlmError('INVALID_REPLAY_STATE')`.
 
 ## Vocabulary differences
 

+ 3 - 0
packages/llm/llm-pi-ai/src/replay.ts

@@ -156,6 +156,9 @@ function foreignAssistant(message: Message): AssistantMessage {
 /** Recombine durable Harness content with validated pi-ai replay metadata. */
 function replayedAssistant(message: Message, rawState: unknown): AssistantMessage {
   const state = readReplayState(rawState)
+  const provenance = message.provenance
+  if (state.provider !== provenance?.provider) return invalidReplay('provider does not match assistant provenance')
+  if (state.model !== provenance.model) return invalidReplay('model does not match assistant provenance')
   if (state.blocks.length !== message.content.length) return invalidReplay('block count does not match assistant content')
   const content: AssistantMessage['content'] = message.content.map((block, index) => {
     const replay = state.blocks[index]

+ 26 - 4
packages/llm/llm-pi-ai/tests/convert.spec.ts

@@ -255,7 +255,7 @@ describe('toPiContext', () => {
           { type: 'text', text: 'calling' },
           { type: 'tool-call', id: CallId('c1'), name: 'f', arguments: '{"a":1}' },
         ],
-        provenance: { provider: 'deepseek', model: 'old-model', replayState: state },
+        provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
       }],
     })
 
@@ -302,7 +302,7 @@ describe('toPiContext', () => {
       messages: [{
         role: 'assistant',
         content: [{ type: 'reasoning', text: 'done' }],
-        provenance: { provider: 'deepseek', model: 'old', replayState: state },
+        provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
       }],
     })).toThrow(/block 0 does not match assistant content/)
   })
@@ -315,7 +315,7 @@ describe('toPiContext', () => {
       messages: [{
         role: 'assistant',
         content: [{ type: 'text', text: 'done' }],
-        provenance: { provider: 'deepseek', model: 'old', replayState: state },
+        provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState: state },
       }],
     })).toThrow(/block count does not match assistant content/)
   })
@@ -330,6 +330,28 @@ describe('toPiContext', () => {
     blocks: [{ type: 'text' }],
   }
 
+  it.each([
+    ['provider', { ...validReplay, provider: 'openai' }],
+    ['model', { ...validReplay, model: 'deepseek-v4-pro' }],
+  ])('rejects replay metadata whose %s differs from assistant provenance', (field, replayState) => {
+    try {
+      toPiContext({
+        provider: 'deepseek',
+        model: 'next-model',
+        messages: [{
+          role: 'assistant',
+          content: [{ type: 'text', text: 'done' }],
+          provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState },
+        }],
+      })
+      expect.fail('expected invalid replay state')
+    } catch (error: unknown) {
+      expect(error).toBeInstanceOf(LlmError)
+      expect((error as LlmError).code).toBe('INVALID_REPLAY_STATE')
+      expect((error as Error).message).toContain(`${field} does not match assistant provenance`)
+    }
+  })
+
   it.each([
     ['number state', 1, 'expected an object'],
     ['null state', null, 'expected an object'],
@@ -355,7 +377,7 @@ describe('toPiContext', () => {
       messages: [{
         role: 'assistant',
         content: [{ type: 'text', text: 'done' }],
-        provenance: { provider: 'deepseek', model: 'old', replayState },
+        provenance: { provider: 'deepseek', model: 'deepseek-v4-flash', replayState },
       }],
     })).toThrow(message)
   })