Skip to content

Commit a05d65b

Browse files
authored
fix(js/plugins/googleai): Handle Gemini API thought signatures. (#3014)
1 parent 1b7d25a commit a05d65b

File tree

2 files changed

+131
-7
lines changed

2 files changed

+131
-7
lines changed

js/plugins/googleai/src/gemini.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,17 @@ function fromCodeExecutionResult(part: GeminiPart): Part {
660660
};
661661
}
662662

663+
function fromThought(part: {
664+
thought: boolean;
665+
text?: string;
666+
thoughtSignature?: string;
667+
}): Part {
668+
return {
669+
reasoning: part.text || '',
670+
metadata: { thoughtSignature: (part as any).thoughtSignature },
671+
};
672+
}
673+
663674
function toCustomPart(part: Part): GeminiPart {
664675
if (!part.custom) {
665676
throw new Error('Invalid GeminiPart: missing custom');
@@ -673,6 +684,14 @@ function toCustomPart(part: Part): GeminiPart {
673684
throw new Error('Unsupported Custom Part type');
674685
}
675686

687+
function toThought(part: Part) {
688+
const outPart: any = { thought: true };
689+
if (part.metadata?.thoughtSignature)
690+
outPart.thoughtSignature = part.metadata.thoughtSignature;
691+
if (part.reasoning?.length) outPart.text = part.reasoning;
692+
return outPart;
693+
}
694+
676695
function toGeminiPart(part: Part): GeminiPart {
677696
if (part.text !== undefined) return { text: part.text || ' ' };
678697
if (part.media) {
@@ -682,6 +701,7 @@ function toGeminiPart(part: Part): GeminiPart {
682701
if (part.toolRequest) return toFunctionCall(part);
683702
if (part.toolResponse) return toFunctionResponse(part);
684703
if (part.custom) return toCustomPart(part);
704+
if (typeof part.reasoning === 'string') return toThought(part);
685705
throw new Error('Unsupported Part type' + JSON.stringify(part));
686706
}
687707

@@ -690,19 +710,16 @@ function fromGeminiPart(
690710
jsonMode: boolean,
691711
ref: string
692712
): Part {
693-
if (part.text !== undefined) {
694-
if ((part as any).thought === true) {
695-
return { reasoning: part.text };
696-
}
697-
return { text: part.text };
698-
}
713+
if ('thought' in part) return fromThought(part as any);
714+
if (typeof part.text === 'string') return { text: part.text };
699715
if (part.inlineData) return fromInlineData(part);
700716
if (part.functionCall) return fromFunctionCall(part, ref);
701717
if (part.functionResponse) return fromFunctionResponse(part);
702718
if (part.executableCode) return fromExecutableCode(part);
703719
if (part.codeExecutionResult) return fromCodeExecutionResult(part);
704-
throw new Error('Unsupported GeminiPart type');
720+
throw new Error('Unsupported GeminiPart type: ' + JSON.stringify(part));
705721
}
722+
706723
export function toGeminiMessage(
707724
message: MessageData,
708725
model?: ModelReference<z.ZodTypeAny>

js/plugins/googleai/tests/gemini_test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,17 @@ describe('toGeminiMessages', () => {
136136
],
137137
},
138138
},
139+
{
140+
should: 'should re-populate thoughtSignature from reasoning metadata',
141+
inputMessage: {
142+
role: 'model',
143+
content: [{ reasoning: '', metadata: { thoughtSignature: 'abc123' } }],
144+
},
145+
expectedOutput: {
146+
role: 'model',
147+
parts: [{ thought: true, thoughtSignature: 'abc123' }],
148+
},
149+
},
139150
];
140151
for (const test of testCases) {
141152
it(test.should, () => {
@@ -365,6 +376,102 @@ describe('fromGeminiCandidate', () => {
365376
},
366377
},
367378
},
379+
{
380+
should:
381+
'should transform gemini candidate to genkit candidate (thought parts) correctly',
382+
geminiCandidate: {
383+
content: {
384+
role: 'model',
385+
parts: [
386+
{
387+
thought: true,
388+
thoughtSignature: 'abc123',
389+
},
390+
{
391+
thought: true,
392+
text: 'thought with text',
393+
thoughtSignature: 'def456',
394+
},
395+
],
396+
},
397+
finishReason: 'STOP',
398+
safetyRatings: [
399+
{
400+
category: 'HARM_CATEGORY_HATE_SPEECH',
401+
probability: 'NEGLIGIBLE',
402+
probabilityScore: 0.11858909,
403+
severity: 'HARM_SEVERITY_NEGLIGIBLE',
404+
severityScore: 0.11456649,
405+
},
406+
{
407+
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
408+
probability: 'NEGLIGIBLE',
409+
probabilityScore: 0.13857833,
410+
severity: 'HARM_SEVERITY_NEGLIGIBLE',
411+
severityScore: 0.11417085,
412+
},
413+
{
414+
category: 'HARM_CATEGORY_HARASSMENT',
415+
probability: 'NEGLIGIBLE',
416+
probabilityScore: 0.28012377,
417+
severity: 'HARM_SEVERITY_NEGLIGIBLE',
418+
severityScore: 0.112405084,
419+
},
420+
{
421+
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
422+
probability: 'NEGLIGIBLE',
423+
},
424+
],
425+
},
426+
expectedOutput: {
427+
index: 0,
428+
message: {
429+
role: 'model',
430+
content: [
431+
{
432+
reasoning: '',
433+
metadata: { thoughtSignature: 'abc123' },
434+
},
435+
{
436+
reasoning: 'thought with text',
437+
metadata: { thoughtSignature: 'def456' },
438+
},
439+
],
440+
},
441+
finishReason: 'stop',
442+
finishMessage: undefined,
443+
custom: {
444+
citationMetadata: undefined,
445+
safetyRatings: [
446+
{
447+
category: 'HARM_CATEGORY_HATE_SPEECH',
448+
probability: 'NEGLIGIBLE',
449+
probabilityScore: 0.11858909,
450+
severity: 'HARM_SEVERITY_NEGLIGIBLE',
451+
severityScore: 0.11456649,
452+
},
453+
{
454+
category: 'HARM_CATEGORY_DANGEROUS_CONTENT',
455+
probability: 'NEGLIGIBLE',
456+
probabilityScore: 0.13857833,
457+
severity: 'HARM_SEVERITY_NEGLIGIBLE',
458+
severityScore: 0.11417085,
459+
},
460+
{
461+
category: 'HARM_CATEGORY_HARASSMENT',
462+
probability: 'NEGLIGIBLE',
463+
probabilityScore: 0.28012377,
464+
severity: 'HARM_SEVERITY_NEGLIGIBLE',
465+
severityScore: 0.112405084,
466+
},
467+
{
468+
category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT',
469+
probability: 'NEGLIGIBLE',
470+
},
471+
],
472+
},
473+
},
474+
},
368475
];
369476
for (const test of testCases) {
370477
it(test.should, () => {

0 commit comments

Comments
 (0)