mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 23:01:30 +00:00
Merge pull request #505 from kim0/fix/google-thoughtsignature-thinking-classification
fix(ai): classify Google thoughtSignature as thinking
This commit is contained in:
commit
43d6912848
5 changed files with 108 additions and 9 deletions
|
|
@ -19,7 +19,14 @@ import type {
|
|||
} from "../types.js";
|
||||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
import { convertMessages, convertTools, mapStopReasonString, mapToolChoice } from "./google-shared.js";
|
||||
import {
|
||||
convertMessages,
|
||||
convertTools,
|
||||
isThinkingPart,
|
||||
mapStopReasonString,
|
||||
mapToolChoice,
|
||||
retainThoughtSignature,
|
||||
} from "./google-shared.js";
|
||||
|
||||
/**
|
||||
* Thinking level for Gemini 3 models.
|
||||
|
|
@ -360,7 +367,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|||
if (candidate?.content?.parts) {
|
||||
for (const part of candidate.content.parts) {
|
||||
if (part.text !== undefined) {
|
||||
const isThinking = part.thought === true;
|
||||
const isThinking = isThinkingPart(part);
|
||||
if (
|
||||
!currentBlock ||
|
||||
(isThinking && currentBlock.type !== "thinking") ||
|
||||
|
|
@ -395,7 +402,10 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|||
}
|
||||
if (currentBlock.type === "thinking") {
|
||||
currentBlock.thinking += part.text;
|
||||
currentBlock.thinkingSignature = part.thoughtSignature;
|
||||
currentBlock.thinkingSignature = retainThoughtSignature(
|
||||
currentBlock.thinkingSignature,
|
||||
part.thoughtSignature,
|
||||
);
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: blockIndex(),
|
||||
|
|
|
|||
|
|
@ -9,6 +9,38 @@ import { transformMessages } from "./transorm-messages.js";
|
|||
|
||||
type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex";
|
||||
|
||||
/**
|
||||
* Determines whether a streamed Gemini `Part` should be treated as "thinking".
|
||||
*
|
||||
* Protocol note (Gemini / Vertex AI thought signatures):
|
||||
* - `thoughtSignature` may appear without `thought: true` (including in empty-text parts at the end of streaming).
|
||||
* - When persisting/replaying model outputs, signature-bearing parts must be preserved as-is;
|
||||
* do not merge/move signatures across parts.
|
||||
* - Our streaming representation uses content blocks, so we classify any non-empty `thoughtSignature`
|
||||
* as thinking to avoid leaking thought content into normal assistant text.
|
||||
*
|
||||
* Some Google backends send thought content with `thoughtSignature` but omit `thought: true`
|
||||
* on subsequent deltas. We treat any non-empty `thoughtSignature` as thinking to avoid
|
||||
* leaking thought text into the normal assistant text stream.
|
||||
*/
|
||||
export function isThinkingPart(part: Pick<Part, "thought" | "thoughtSignature">): boolean {
|
||||
return part.thought === true || (typeof part.thoughtSignature === "string" && part.thoughtSignature.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retain thought signatures during streaming.
|
||||
*
|
||||
* Some backends only send `thoughtSignature` on the first delta for a given part/block; later deltas may omit it.
|
||||
* This helper preserves the last non-empty signature for the current block.
|
||||
*
|
||||
* Note: this does NOT merge or move signatures across distinct response parts. It only prevents
|
||||
* a signature from being overwritten with `undefined` within the same streamed block.
|
||||
*/
|
||||
export function retainThoughtSignature(existing: string | undefined, incoming: string | undefined): string | undefined {
|
||||
if (typeof incoming === "string" && incoming.length > 0) return incoming;
|
||||
return existing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert internal messages to Gemini Content[] format.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -20,7 +20,14 @@ import type {
|
|||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
import type { GoogleThinkingLevel } from "./google-gemini-cli.js";
|
||||
import { convertMessages, convertTools, mapStopReason, mapToolChoice } from "./google-shared.js";
|
||||
import {
|
||||
convertMessages,
|
||||
convertTools,
|
||||
isThinkingPart,
|
||||
mapStopReason,
|
||||
mapToolChoice,
|
||||
retainThoughtSignature,
|
||||
} from "./google-shared.js";
|
||||
|
||||
export interface GoogleVertexOptions extends StreamOptions {
|
||||
toolChoice?: "auto" | "none" | "any";
|
||||
|
|
@ -88,7 +95,7 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
|
|||
if (candidate?.content?.parts) {
|
||||
for (const part of candidate.content.parts) {
|
||||
if (part.text !== undefined) {
|
||||
const isThinking = part.thought === true;
|
||||
const isThinking = isThinkingPart(part);
|
||||
if (
|
||||
!currentBlock ||
|
||||
(isThinking && currentBlock.type !== "thinking") ||
|
||||
|
|
@ -123,7 +130,10 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = (
|
|||
}
|
||||
if (currentBlock.type === "thinking") {
|
||||
currentBlock.thinking += part.text;
|
||||
currentBlock.thinkingSignature = part.thoughtSignature;
|
||||
currentBlock.thinkingSignature = retainThoughtSignature(
|
||||
currentBlock.thinkingSignature,
|
||||
part.thoughtSignature,
|
||||
);
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: blockIndex(),
|
||||
|
|
|
|||
|
|
@ -20,7 +20,14 @@ import type {
|
|||
import { AssistantMessageEventStream } from "../utils/event-stream.js";
|
||||
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
|
||||
import type { GoogleThinkingLevel } from "./google-gemini-cli.js";
|
||||
import { convertMessages, convertTools, mapStopReason, mapToolChoice } from "./google-shared.js";
|
||||
import {
|
||||
convertMessages,
|
||||
convertTools,
|
||||
isThinkingPart,
|
||||
mapStopReason,
|
||||
mapToolChoice,
|
||||
retainThoughtSignature,
|
||||
} from "./google-shared.js";
|
||||
|
||||
export interface GoogleOptions extends StreamOptions {
|
||||
toolChoice?: "auto" | "none" | "any";
|
||||
|
|
@ -75,7 +82,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = (
|
|||
if (candidate?.content?.parts) {
|
||||
for (const part of candidate.content.parts) {
|
||||
if (part.text !== undefined) {
|
||||
const isThinking = part.thought === true;
|
||||
const isThinking = isThinkingPart(part);
|
||||
if (
|
||||
!currentBlock ||
|
||||
(isThinking && currentBlock.type !== "thinking") ||
|
||||
|
|
@ -110,7 +117,10 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = (
|
|||
}
|
||||
if (currentBlock.type === "thinking") {
|
||||
currentBlock.thinking += part.text;
|
||||
currentBlock.thinkingSignature = part.thoughtSignature;
|
||||
currentBlock.thinkingSignature = retainThoughtSignature(
|
||||
currentBlock.thinkingSignature,
|
||||
part.thoughtSignature,
|
||||
);
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: blockIndex(),
|
||||
|
|
|
|||
37
packages/ai/test/google-thinking-signature.test.ts
Normal file
37
packages/ai/test/google-thinking-signature.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isThinkingPart, retainThoughtSignature } from "../src/providers/google-shared.js";
|
||||
|
||||
describe("Google thinking detection (thoughtSignature)", () => {
|
||||
it("treats part.thought === true as thinking", () => {
|
||||
expect(isThinkingPart({ thought: true, thoughtSignature: undefined })).toBe(true);
|
||||
});
|
||||
|
||||
it("treats a non-empty thoughtSignature as thinking even if thought is missing", () => {
|
||||
// This is the bug: some backends omit `thought: true` but still include `thoughtSignature`
|
||||
expect(isThinkingPart({ thought: undefined, thoughtSignature: "opaque-signature" })).toBe(true);
|
||||
expect(isThinkingPart({ thought: false, thoughtSignature: "opaque-signature" })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat empty/missing signatures as thinking if thought is not set", () => {
|
||||
expect(isThinkingPart({ thought: undefined, thoughtSignature: undefined })).toBe(false);
|
||||
expect(isThinkingPart({ thought: false, thoughtSignature: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the existing signature when subsequent deltas omit thoughtSignature", () => {
|
||||
const first = retainThoughtSignature(undefined, "sig-1");
|
||||
expect(first).toBe("sig-1");
|
||||
|
||||
const second = retainThoughtSignature(first, undefined);
|
||||
expect(second).toBe("sig-1");
|
||||
|
||||
const third = retainThoughtSignature(second, "");
|
||||
expect(third).toBe("sig-1");
|
||||
});
|
||||
|
||||
it("updates the signature when a new non-empty signature arrives", () => {
|
||||
const updated = retainThoughtSignature("sig-1", "sig-2");
|
||||
expect(updated).toBe("sig-2");
|
||||
});
|
||||
|
||||
// Note: signature-only parts (empty text + thoughtSignature) are handled by isThinkingPart via thoughtSignature presence.
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue