fix(ai): replay Claude assistant turns verbatim so thinking blocks keep their signature
Claude Sonnet 5.5 는 응답에 thinking 블록을 포함한다(adaptive thinking, 비활성화 불가). 어댑터가 어시스턴트 턴을 자체 구조체로 재직렬화하면서 thinking 본문과 signature 를 떨어뜨려 두 번째 턴에서 400(thinking.thinking: Field required)이 났다(2026-10-15 C-G1 실측). 응답 content 를 json.RawMessage 로 보관해 그대로 재전송한다. 테스트에 thinking 블록 재전송 검증을 추가.
This commit is contained in:
1 parent
684e70ba60
commit
f671e2419f
2 files changed
+12
-4
No files matched your search
@@ -139,7 +139,7 @@ type response struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
StopReason string `json:"stop_reason"`
|
StopReason string `json:"stop_reason"`
|
||||||
Content []block `json:"content"`
|
Content json.RawMessage `json:"content"` // kept verbatim: thinking blocks (with their signature) must be replayed unchanged
|
||||||
Usage struct {
|
Usage struct {
|
||||||
InputTokens int `json:"input_tokens"`
|
InputTokens int `json:"input_tokens"`
|
||||||
OutputTokens int `json:"output_tokens"`
|
OutputTokens int `json:"output_tokens"`
|
||||||
@@ -275,8 +275,10 @@ func (c *Client) CreateResponse(ctx context.Context, req openai.Request) (*opena
|
|||||||
out.ID = fmt.Sprintf("msg_local_%d", c.seq)
|
out.ID = fmt.Sprintf("msg_local_%d", c.seq)
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
var blocks []block
|
||||||
|
_ = json.Unmarshal(r.Content, &blocks)
|
||||||
var text strings.Builder
|
var text strings.Builder
|
||||||
for _, bl := range r.Content {
|
for _, bl := range blocks {
|
||||||
switch bl.Type {
|
switch bl.Type {
|
||||||
case "text":
|
case "text":
|
||||||
text.WriteString(bl.Text)
|
text.WriteString(bl.Text)
|
||||||
@@ -296,7 +298,9 @@ func (c *Client) CreateResponse(ctx context.Context, req openai.Request) (*opena
|
|||||||
out.Usage.OutputTokens = r.Usage.OutputTokens
|
out.Usage.OutputTokens = r.Usage.OutputTokens
|
||||||
out.Usage.TotalTokens = out.Usage.InputTokens + out.Usage.OutputTokens
|
out.Usage.TotalTokens = out.Usage.InputTokens + out.Usage.OutputTokens
|
||||||
|
|
||||||
// remember the conversation for the next turn (assistant content kept verbatim so tool_use ids line up)
|
// remember the conversation for the next turn: the assistant content is replayed as the raw JSON the API returned,
|
||||||
|
// so tool_use ids and thinking blocks (adaptive thinking is always on for Claude 5.5; a thinking block replayed
|
||||||
|
// without its text/signature is rejected with "thinking.thinking: Field required") stay intact
|
||||||
full := append(append([]message{}, msgs...), message{Role: "assistant", Content: r.Content})
|
full := append(append([]message{}, msgs...), message{Role: "assistant", Content: r.Content})
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.conv[out.ID] = conversation{system: body.System, msgs: full}
|
c.conv[out.ID] = conversation{system: body.System, msgs: full}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestTranslateTurns(t *testing.T) {
|
|||||||
bodies = append(bodies, b)
|
bodies = append(bodies, b)
|
||||||
switch len(bodies) {
|
switch len(bodies) {
|
||||||
case 1:
|
case 1:
|
||||||
w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-5-5","stop_reason":"tool_use","content":[{"type":"text","text":"checking"},{"type":"tool_use","id":"toolu_1","name":"get_probe_status","input":{}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_creation_input_tokens":5,"cache_read_input_tokens":0}}`))
|
w.Write([]byte(`{"id":"msg_1","type":"message","model":"claude-sonnet-5-5","stop_reason":"tool_use","content":[{"type":"thinking","thinking":"let me probe","signature":"sig-abc"},{"type":"text","text":"checking"},{"type":"tool_use","id":"toolu_1","name":"get_probe_status","input":{}}],"usage":{"input_tokens":100,"output_tokens":20,"cache_creation_input_tokens":5,"cache_read_input_tokens":0}}`))
|
||||||
default:
|
default:
|
||||||
w.Write([]byte(`{"id":"msg_2","type":"message","model":"claude-sonnet-5-5","stop_reason":"tool_use","content":[{"type":"tool_use","id":"toolu_2","name":"submit_diagnosis","input":{"root_cause_label":"x","confidence":0.9}}],"usage":{"input_tokens":200,"output_tokens":30}}`))
|
w.Write([]byte(`{"id":"msg_2","type":"message","model":"claude-sonnet-5-5","stop_reason":"tool_use","content":[{"type":"tool_use","id":"toolu_2","name":"submit_diagnosis","input":{"root_cause_label":"x","confidence":0.9}}],"usage":{"input_tokens":200,"output_tokens":30}}`))
|
||||||
}
|
}
|
||||||
@@ -75,6 +75,10 @@ func TestTranslateTurns(t *testing.T) {
|
|||||||
if as["role"] != "assistant" || !strings.Contains(mustJSON(as["content"]), `"tool_use"`) {
|
if as["role"] != "assistant" || !strings.Contains(mustJSON(as["content"]), `"tool_use"`) {
|
||||||
t.Fatalf("assistant turn not replayed: %v", as)
|
t.Fatalf("assistant turn not replayed: %v", as)
|
||||||
}
|
}
|
||||||
|
// the thinking block must come back verbatim with its text and signature (Claude 5.5 rejects a stripped one)
|
||||||
|
if ac := mustJSON(as["content"]); !strings.Contains(ac, `"thinking":"let me probe"`) || !strings.Contains(ac, `"signature":"sig-abc"`) {
|
||||||
|
t.Fatalf("thinking block not replayed verbatim: %s", ac)
|
||||||
|
}
|
||||||
last := msgs[2].(map[string]any)["content"].([]any)
|
last := msgs[2].(map[string]any)["content"].([]any)
|
||||||
if tr := last[0].(map[string]any); tr["type"] != "tool_result" || tr["tool_use_id"] != "toolu_1" || tr["content"] != `{"alb":1}` {
|
if tr := last[0].(map[string]any); tr["type"] != "tool_result" || tr["tool_use_id"] != "toolu_1" || tr["content"] != `{"alb":1}` {
|
||||||
t.Fatalf("tool_result: %v", tr)
|
t.Fatalf("tool_result: %v", tr)
|
||||||
|
|||||||
Reference in new issue
Block a user