Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 123 additions & 1 deletion front/lib/api/llm/transitionLLM.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ const serverToolUseBlock = {
function reasoningMessage({
provider,
metadata,
reasoning = "let me think",
}: {
provider: ModelProviderIdType;
metadata: string;
reasoning?: string;
}) {
return {
role: "assistant" as const,
Expand All @@ -48,7 +50,7 @@ function reasoningMessage({
{
type: "reasoning" as const,
value: {
reasoning: "let me think",
reasoning,
metadata,
tokens: 10,
provider,
Expand Down Expand Up @@ -240,6 +242,23 @@ describe("toBaseMessages — reasoning signatures", () => {
},
]);
});

it("keeps a Fireworks Responses reasoning id in `signature`", () => {
const result = toBaseMessages(
reasoningMessage({
provider: "fireworks",
metadata: JSON.stringify({ id: "rs_fireworks_123" }),
})
);
expect(result).toEqual([
{
role: "assistant",
type: "reasoning",
content: { value: "let me think" },
signature: "rs_fireworks_123",
},
]);
});
});

describe("OpenAI reasoning round-trip — persisted metadata to Responses input", () => {
Expand Down Expand Up @@ -267,6 +286,109 @@ describe("OpenAI reasoning round-trip — persisted metadata to Responses input"
});
});

describe("Fireworks reasoning round-trip — persisted metadata to Responses input", () => {
it("replays an id-bearing reasoning item with an empty summary", () => {
const [baseMessage] = toBaseMessages(
reasoningMessage({
provider: "fireworks",
metadata: JSON.stringify({ id: "rs_fireworks_empty" }),
reasoning: "",
})
);
if (baseMessage.type !== "reasoning") {
throw new Error("Expected a reasoning BaseMessage.");
}

expect(assistantReasoningMessageToInputItems(baseMessage)).toEqual([
{
id: "rs_fireworks_empty",
type: "reasoning",
summary: [],
},
]);
});

it("preserves interleaved reasoning ids and tool calls in wire order", () => {
const message: ModelMessageTypeMultiActionsWithoutContentFragment = {
role: "assistant",
name: "agent",
contents: [
{
type: "reasoning",
value: {
reasoning: "first thought",
metadata: JSON.stringify({ id: "rs_fireworks_1" }),
tokens: 10,
provider: "fireworks",
},
},
{
type: "function_call",
value: {
id: "call_1",
name: "first_tool",
arguments: "{}",
},
},
{
type: "reasoning",
value: {
reasoning: "second thought",
metadata: JSON.stringify({ id: "rs_fireworks_2" }),
tokens: 10,
provider: "fireworks",
},
},
{
type: "function_call",
value: {
id: "call_2",
name: "second_tool",
arguments: "{}",
},
},
],
};

expect(toBaseMessages(message)).toEqual([
{
role: "assistant",
type: "reasoning",
content: { value: "first thought" },
signature: "rs_fireworks_1",
},
{
role: "assistant",
type: "tool_call_request",
content: {
callId: "call_1",
toolName: "first_tool",
arguments: "{}",
namespace: undefined,
},
signature: undefined,
},
{
role: "assistant",
type: "reasoning",
content: { value: "second thought" },
signature: "rs_fireworks_2",
},
{
role: "assistant",
type: "tool_call_request",
content: {
callId: "call_2",
toolName: "second_tool",
arguments: "{}",
namespace: undefined,
},
signature: undefined,
},
]);
});
});

describe("convertToOldEvent — token_usage", () => {
it("sums the per-TTL cache-creation breakdown into cacheCreationTokens and keeps the split", () => {
expect(
Expand Down
46 changes: 26 additions & 20 deletions front/lib/api/llm/transitionLLM.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,35 +221,40 @@ export function toBaseMessages(
},
];
case "reasoning": {
if (!c.value.reasoning) {
const reasoning = c.value.reasoning ?? "";
const { id, encryptedContent } = parseReasoningMetadata(
c.value.metadata
);
// Empty reasoning summaries can still carry replay state. Some
// reasoners emit such items on tool-use turns, so only discard
// an empty block when it has neither an item id nor encrypted
// content.
if (!reasoning && !id && !encryptedContent) {
return [];
}
// OpenAI Responses stores a short reasoning item `id` (kept in
// `signature`) separately from the long `encrypted_content`. Every
// other provider stores its thinking signature under
// `encrypted_content`, which we carry directly in `signature`.
if (c.value.provider !== "openai") {
// Responses APIs store a short reasoning item `id` (kept in
// `signature`) separately from the long `encrypted_content`.
// Detect that wire format from the persisted id rather than the
// model provider: Fireworks also exposes the Responses API.
if (id || c.value.provider === "openai") {
return [
{
role: "assistant",
type: "reasoning",
content: { value: c.value.reasoning },
signature: extractEncryptedContentFromMetadata(
c.value.metadata
),
content: { value: reasoning },
signature: id,
...(encryptedContent ? { encryptedContent } : {}),
},
];
}
const { id, encryptedContent } = parseReasoningMetadata(
c.value.metadata
);
return [
{
role: "assistant",
type: "reasoning",
content: { value: c.value.reasoning },
signature: id,
encryptedContent,
content: { value: reasoning },
signature: extractEncryptedContentFromMetadata(
c.value.metadata
),
},
];
}
Expand Down Expand Up @@ -337,10 +342,11 @@ export function withMessageCacheBreakpoints(
return result;
}

// The new router nests reasoning replay state under `metadata.content`: OpenAI
// uses `id` + `encryptedContent`, Anthropic/Gemini use `signature`. Persist it in
// the legacy top-level shape (`id` / `encrypted_content`) the replay path reads,
// matching what the old router writes.
// The new router nests reasoning replay state under `metadata.content`:
// Responses APIs use `id` + `encryptedContent`, while other APIs use
// `signature`. Persist it in the legacy top-level shape (`id` /
// `encrypted_content`) the replay path reads, matching what the old router
// writes.
export function reasoningContentToLegacyMetadata(
content: Record<string, unknown> | undefined
): { id?: string; encrypted_content?: string } {
Expand Down
4 changes: 2 additions & 2 deletions front/lib/api/llm/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export function extractEncryptedContentFromMetadata(metadata: string): string {
return encryptedContent;
}

// OpenAI Responses replays a reasoning item by its short `id` and (for ZDR
// regions) its long `encrypted_content`.
// Responses APIs replay a reasoning item by its short `id` and, when returned,
// its long `encrypted_content`.
export function parseReasoningMetadata(metadata: string): {
id: string;
encryptedContent: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe("Kimi K3 model configuration", () => {
);
});

it("uses Fireworks Priority with its matching token prices", () => {
it("uses Fireworks Responses in priority mode without storage", () => {
const endpoint = new MoonshotAiKimiK3GlobalFireworksStream({
FIREWORKS_API_KEY: "test",
});
Expand All @@ -46,6 +46,8 @@ describe("Kimi K3 model configuration", () => {
MoonshotAiKimiK3GlobalFireworksStream.configSchema.parse({})
);

expect(payload.model).toBe("accounts/fireworks/models/kimi-k3");
expect(payload.store).toBe(false);
expect(payload.service_tier).toBe("priority");
expect(MoonshotAiKimiK3GlobalFireworksStream.tokenPricing).toEqual({
cacheHit: 0.375,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@ const DEFAULT_REASONING_EFFORT = "maximal";
// (https://docs.fireworks.ai/guides/reasoning), so it is not a contradiction —
// we follow the model author. Our `maximal` maps to Moonshot's `max`.
//
// Confirmed live through Fireworks on 2026-07-27 with the widest
// `inputConfigSchema`: low/high/max all reason. The gateway is looser than
// either doc — it also accepts `medium`, `xhigh` and `none` (which does turn
// Confirmed live through Fireworks' Responses API on 2026-07-28:
// low/high/max all reason. A 2026-07-27 broad gateway characterization also
// found that Fireworks accepts `medium`, `xhigh` and `none` (which does turn
// thinking off, despite K3 being described as "thinking permanently enabled"),
// and rejects only `minimal`. We expose the documented set only, since
// undocumented efforts can change without notice.
Expand Down
Loading
Loading