Q80: turns and replies the provider cut short fail instead of passing as finished
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d96967cfb9
commit
0970b84336
14 files changed
+208
-37
No files matched your search
@@ -25,6 +25,8 @@ import { authorizeWithParser, refreshUnsupported, revokeLocalProfile } from "./p
|
||||
import { getJson, postEventStream, postJson, type JsonRecord } from "./provider-http";
|
||||
import { fetchAnthropicModels, fetchOpenAiModels } from "./provider-models-fetch";
|
||||
import { KIMI_CODE_BASE_URL } from "./oauth/kimi-code-flow";
|
||||
import { FINISHED_STOP_REASONS, incompleteTurn, requireFinished } from "./provider-tool-turn";
|
||||
import { incompleteReason } from "./provider-tool-turns/responses-format";
|
||||
|
||||
const ANTHROPIC_VERSION = "2023-06-01";
|
||||
const CLAUDE_PRO_BETA = "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14";
|
||||
@@ -124,6 +126,7 @@ export const providerAdapters: ProviderAdapterRegistry = {
|
||||
...(input.instructions ? { instructions: input.instructions } : {}),
|
||||
});
|
||||
const data = response.data;
|
||||
if (data.status !== "completed") throw incompleteTurn(incompleteReason(data));
|
||||
const text = requireText(collectText(data.output_text ?? data.output), "OpenAI");
|
||||
return { text, rawResponseJson: JSON.stringify(data), rateLimitState: response.rateLimitState };
|
||||
},
|
||||
@@ -177,6 +180,7 @@ export const providerAdapters: ProviderAdapterRegistry = {
|
||||
messages: [{ role: "user", content: input.prompt }],
|
||||
});
|
||||
const data = response.data;
|
||||
requireFinished(data.stop_reason, FINISHED_STOP_REASONS.anthropic);
|
||||
const text = requireText(collectText(data.content), "Anthropic");
|
||||
return { text, rawResponseJson: JSON.stringify(data), rateLimitState: response.rateLimitState };
|
||||
},
|
||||
@@ -416,7 +420,8 @@ function openAiCompatibleAdapter({
|
||||
messages,
|
||||
});
|
||||
const data = response.data;
|
||||
const text = requireText(collectText(data.choices), label);
|
||||
requireFinished(Array.isArray(data.choices) && isRecord(data.choices[0]) ? data.choices[0].finish_reason : null, FINISHED_STOP_REASONS.chat);
|
||||
const text = requireText(collectText(Array.isArray(data.choices) ? data.choices.filter(isRecord).map((choice) => choice.message) : []), label);
|
||||
return { text, rawResponseJson: JSON.stringify(data), rateLimitState: response.rateLimitState };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { ProviderRateLimitRegistry } from "./provider-rate-limit-registry";
|
||||
import type { ProviderRequestStore } from "./provider-request-store";
|
||||
import type { ProviderResponseObserver } from "./provider-response-observer";
|
||||
import { hashPrompt, readProviderResponseId, requireStreamText } from "./provider-response-utils";
|
||||
import { type AgentTurnMessage, type AgentTurnResult, type ToolDefinition, type ToolTurnFn } from "./provider-tool-turn";
|
||||
import { incompleteTurn, type AgentTurnMessage, type AgentTurnResult, type ToolDefinition, type ToolTurnFn } from "./provider-tool-turn";
|
||||
import { selectToolTurn, supportsToolTurn } from "./provider-tool-turns/router";
|
||||
import { resolveTurnCredential } from "./provider-tool-turns/credential-resolver";
|
||||
import { currentRunId } from "./run-control";
|
||||
@@ -201,6 +201,7 @@ export class ProviderResponseRunner {
|
||||
const rawEvents: string[] = [];
|
||||
const textParts: string[] = [];
|
||||
let providerResponseId: string | null = null;
|
||||
let finished = false;
|
||||
try {
|
||||
const authProfile = execution.adapter.parseAuthProfile(execution.credential.secret);
|
||||
const result = await execution.adapter.streamResponse({
|
||||
@@ -214,6 +215,7 @@ export class ProviderResponseRunner {
|
||||
if (event.type === "error") throw new Error(event.textDelta ?? "Provider stream failed.");
|
||||
rawEvents.push(event.rawEventJson);
|
||||
if (event.type === "usage") continue;
|
||||
if (event.type === "message_stop") finished = true;
|
||||
if (event.providerResponseId) providerResponseId = event.providerResponseId;
|
||||
if (event.textDelta) textParts.push(event.textDelta);
|
||||
const responseText = textParts.join("");
|
||||
@@ -244,8 +246,9 @@ export class ProviderResponseRunner {
|
||||
streamEventCount,
|
||||
});
|
||||
}
|
||||
if (!finished) throw incompleteTurn(null);
|
||||
const responseText = requireStreamText(textParts);
|
||||
const finished = this.deps.requestStore.markSucceeded({
|
||||
const succeeded = this.deps.requestStore.markSucceeded({
|
||||
orgId: input.orgId,
|
||||
requestId,
|
||||
responseText,
|
||||
@@ -254,7 +257,7 @@ export class ProviderResponseRunner {
|
||||
externalResponseId: providerResponseId,
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
if (!finished) return requestId;
|
||||
if (!succeeded) return requestId;
|
||||
this.deps.responseObserver.recordSuccess({
|
||||
orgId: input.orgId,
|
||||
provider: execution.provider,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ProviderObservedRateLimitState } from "../../shared/provider-runtime-domain";
|
||||
import type { JsonRecord, ProviderEventStreamMessage } from "./provider-http";
|
||||
import { isRecord } from "./provider-http";
|
||||
import { FINISHED_STOP_REASONS, incompleteTurn } from "./provider-tool-turn";
|
||||
import { incompleteReason } from "./provider-tool-turns/responses-format";
|
||||
|
||||
export interface ProviderStreamEvent {
|
||||
/** usage: a chunk that only reports token usage; it is recorded, never shown. */
|
||||
@@ -41,7 +43,12 @@ export async function* mapOpenAiResponseStream(messages: AsyncIterable<ProviderE
|
||||
if (delta) yield streamEvent("text_delta", delta, message);
|
||||
continue;
|
||||
}
|
||||
if (type === "response.completed" || type === "response.incomplete") {
|
||||
if (type === "response.incomplete") {
|
||||
const response = recordField(message.data, "response") ?? {};
|
||||
yield streamEvent("error", incompleteTurn(incompleteReason(response)).message, message);
|
||||
return;
|
||||
}
|
||||
if (type === "response.completed") {
|
||||
if (!started) {
|
||||
started = true;
|
||||
yield streamEvent("message_start", null, message);
|
||||
@@ -66,7 +73,12 @@ export async function* mapOpenAiChatCompletionStream(messages: AsyncIterable<Pro
|
||||
}
|
||||
yield streamEvent("text_delta", delta, message);
|
||||
}
|
||||
if (hasChatFinish(message.data)) {
|
||||
const finish = chatFinishReason(message.data);
|
||||
if (finish && !(FINISHED_STOP_REASONS.chat as readonly string[]).includes(finish)) {
|
||||
yield streamEvent("error", incompleteTurn(finish).message, message);
|
||||
return;
|
||||
}
|
||||
if (finish) {
|
||||
if (!started) {
|
||||
started = true;
|
||||
yield streamEvent("message_start", null, message);
|
||||
@@ -89,6 +101,11 @@ export async function* mapAnthropicMessageStream(messages: AsyncIterable<Provide
|
||||
}
|
||||
// Anthropic reports output tokens on message_delta, input tokens on message_start.
|
||||
if (type === "message_delta") {
|
||||
const stopReason = stringField(recordField(message.data, "delta") ?? {}, "stop_reason");
|
||||
if (stopReason && !(FINISHED_STOP_REASONS.anthropic as readonly string[]).includes(stopReason)) {
|
||||
yield streamEvent("error", incompleteTurn(stopReason).message, message);
|
||||
return;
|
||||
}
|
||||
yield streamEvent("usage", null, message);
|
||||
continue;
|
||||
}
|
||||
@@ -145,8 +162,9 @@ function readChatDeltaContent(data: JsonRecord): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasChatFinish(data: JsonRecord): boolean {
|
||||
return recordArray(data.choices).some((choice) => typeof choice.finish_reason === "string" && choice.finish_reason.length > 0);
|
||||
function chatFinishReason(data: JsonRecord): string | null {
|
||||
const reason = recordArray(data.choices).map((choice) => choice.finish_reason).find((value) => typeof value === "string" && value.length > 0);
|
||||
return typeof reason === "string" ? reason : null;
|
||||
}
|
||||
|
||||
function readAnthropicTextDelta(data: JsonRecord): string | null {
|
||||
|
||||
@@ -79,6 +79,24 @@ export interface ProviderToolTurnInput {
|
||||
|
||||
export type ToolTurnFn = (input: ProviderToolTurnInput) => Promise<AgentTurnResult>;
|
||||
|
||||
/** Stop reasons that mean the model finished its turn, per wire format, as Pi maps them. */
|
||||
export const FINISHED_STOP_REASONS = {
|
||||
chat: ["stop", "end", "tool_calls", "function_call"],
|
||||
anthropic: ["end_turn", "tool_use", "stop_sequence"],
|
||||
bedrock: ["end_turn", "tool_use", "stop_sequence"],
|
||||
gemini: ["STOP"],
|
||||
} as const;
|
||||
|
||||
/** The error for a turn the provider cut short; acting on half a turn's text or arguments is worse than failing. */
|
||||
export function incompleteTurn(reason: unknown): Error {
|
||||
const named = typeof reason === "string" && reason ? reason : "no stop reason";
|
||||
return new Error(`The provider stopped before finishing its answer (${named}); the partial answer was not used.`);
|
||||
}
|
||||
|
||||
export function requireFinished(reason: unknown, finished: readonly string[]): void {
|
||||
if (typeof reason !== "string" || !finished.includes(reason)) throw incompleteTurn(reason);
|
||||
}
|
||||
|
||||
/** Coerce a tool-call argument payload (string or object) into a JSON object. */
|
||||
export function parseArguments(value: unknown): JsonRecord {
|
||||
if (isRecord(value)) return value;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Anthropic Messages API encoding/decoding, shared by anthropic + claude-pro. */
|
||||
import { isRecord, type JsonRecord } from "../provider-http";
|
||||
import { replayIn, type AgentTurnMessage, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
import { FINISHED_STOP_REASONS, replayIn, requireFinished, type AgentTurnMessage, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
|
||||
export function anthropicToolDefs(tools: ToolDefinition[]): unknown[] {
|
||||
return tools.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.parameters }));
|
||||
@@ -27,6 +27,7 @@ export function toAnthropicMessages(messages: AgentTurnMessage[]): unknown[] {
|
||||
}
|
||||
|
||||
export function parseAnthropicContent(data: JsonRecord): { text: string; toolCalls: ToolCall[]; replay: AssistantReplay } {
|
||||
requireFinished(data.stop_reason, FINISHED_STOP_REASONS.anthropic);
|
||||
const content = Array.isArray(data.content) ? data.content : [];
|
||||
const textParts: string[] = [];
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** AWS Bedrock Converse API encoding/decoding (ported from Pi's bedrock handler). */
|
||||
import { isRecord, type JsonRecord } from "../provider-http";
|
||||
import { replayIn, type AgentTurnMessage, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
import { FINISHED_STOP_REASONS, replayIn, requireFinished, type AgentTurnMessage, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
|
||||
export function bedrockToolConfig(tools: ToolDefinition[]): JsonRecord {
|
||||
return {
|
||||
@@ -30,6 +30,7 @@ export function toBedrockMessages(messages: AgentTurnMessage[]): JsonRecord[] {
|
||||
}
|
||||
|
||||
export function parseBedrockOutput(data: JsonRecord): { text: string; toolCalls: ToolCall[]; replay: AssistantReplay } {
|
||||
requireFinished(data.stopReason, FINISHED_STOP_REASONS.bedrock);
|
||||
const output = isRecord(data.output) ? data.output : null;
|
||||
const message = output && isRecord(output.message) ? output.message : null;
|
||||
const content = message && Array.isArray(message.content) ? message.content : [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** OpenAI Chat Completions encoding/decoding (xAI, Groq, Mistral, Cerebras, OpenRouter, Kimi family). */
|
||||
import { isRecord, ProviderHttpError, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http";
|
||||
import { parseArguments, replayIn, type AgentTurnMessage, type AgentTurnResult, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
import { FINISHED_STOP_REASONS, parseArguments, replayIn, requireFinished, type AgentTurnMessage, type AgentTurnResult, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
|
||||
/** Fields providers use for reasoning text; the first non-empty one is the provider's, as in Pi. */
|
||||
const REASONING_FIELDS = ["reasoning_content", "reasoning", "reasoning_text"] as const;
|
||||
@@ -35,6 +35,7 @@ export function toChatMessages(instructions: string | null, messages: AgentTurnM
|
||||
export function parseChatChoice(data: JsonRecord): { text: string; toolCalls: ToolCall[]; replay?: AssistantReplay } {
|
||||
const choices = Array.isArray(data.choices) ? data.choices : [];
|
||||
const first = choices.length > 0 && isRecord(choices[0]) ? choices[0] : null;
|
||||
requireFinished(first?.finish_reason, FINISHED_STOP_REASONS.chat);
|
||||
const message = first && isRecord(first.message) ? first.message : null;
|
||||
if (!message) return { text: "", toolCalls: [] };
|
||||
const text = typeof message.content === "string" ? message.content : "";
|
||||
@@ -78,11 +79,13 @@ export async function accumulateChatStream(messages: AsyncIterable<ProviderEvent
|
||||
const fragments = new Map<number, { id: string; name: string; args: string }>();
|
||||
const reasoning = { field: "", text: "" };
|
||||
const details: JsonRecord[] = [];
|
||||
let finishReason: unknown = null;
|
||||
for await (const message of messages) {
|
||||
raw.push(message.rawData);
|
||||
if (message.data.error !== undefined) throw new ProviderHttpError(streamErrorText(message.data.error), null, streamErrorStatus(message.data.error));
|
||||
const choices = Array.isArray(message.data.choices) ? message.data.choices : [];
|
||||
const first = choices.length > 0 && isRecord(choices[0]) ? choices[0] : null;
|
||||
if (first && typeof first.finish_reason === "string") finishReason = first.finish_reason;
|
||||
const delta = first && isRecord(first.delta) ? first.delta : null;
|
||||
if (!delta) continue;
|
||||
if (typeof delta.content === "string") textParts.push(delta.content);
|
||||
@@ -102,6 +105,7 @@ export async function accumulateChatStream(messages: AsyncIterable<ProviderEvent
|
||||
}
|
||||
}
|
||||
if (raw.length === 0) throw new Error("Chat completions stream ended without any events.");
|
||||
requireFinished(finishReason, FINISHED_STOP_REASONS.chat);
|
||||
const toolCalls: ToolCall[] = [];
|
||||
for (const [, fragment] of [...fragments.entries()].sort(([a], [b]) => a - b)) {
|
||||
if (fragment.id && fragment.name) toolCalls.push({ id: fragment.id, name: fragment.name, arguments: parseArguments(fragment.args) });
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Antigravity request wrapper in google-gemini-cli.
|
||||
*/
|
||||
import { isRecord, ProviderHttpError, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http";
|
||||
import { replayIn, type AgentTurnMessage, type AgentTurnResult, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
import { FINISHED_STOP_REASONS, replayIn, requireFinished, type AgentTurnMessage, type AgentTurnResult, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
|
||||
const ANTIGRAVITY_SYSTEM_INSTRUCTION =
|
||||
"You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding." +
|
||||
@@ -127,6 +127,7 @@ function partsToResult(parts: unknown[]): { text: string[]; toolCalls: ToolCall[
|
||||
/** Parse a non-streaming Gemini generateContent response (Vertex). */
|
||||
export function parseGeminiResponse(data: JsonRecord): { text: string; toolCalls: ToolCall[]; replay: AssistantReplay } {
|
||||
const candidates = Array.isArray(data.candidates) ? data.candidates : [];
|
||||
requireFinished(isRecord(candidates[0]) ? candidates[0].finishReason : null, FINISHED_STOP_REASONS.gemini);
|
||||
const content = candidates.length > 0 && isRecord(candidates[0]) && isRecord(candidates[0].content) ? candidates[0].content : null;
|
||||
const parts = content && Array.isArray(content.parts) ? content.parts : [];
|
||||
const result = partsToResult(parts);
|
||||
@@ -138,6 +139,7 @@ export async function accumulateGeminiStream(messages: AsyncIterable<ProviderEve
|
||||
const textParts: string[] = [];
|
||||
const toolCalls: ToolCall[] = [];
|
||||
const replayParts: unknown[] = [];
|
||||
let finishReason: unknown = null;
|
||||
let counter = 0;
|
||||
for await (const message of messages) {
|
||||
raw.push(message.rawData);
|
||||
@@ -148,6 +150,7 @@ export async function accumulateGeminiStream(messages: AsyncIterable<ProviderEve
|
||||
const response = isRecord(message.data.response) ? message.data.response : null;
|
||||
if (!response) continue;
|
||||
const candidates = Array.isArray(response.candidates) ? response.candidates : [];
|
||||
if (isRecord(candidates[0]) && typeof candidates[0].finishReason === "string") finishReason = candidates[0].finishReason;
|
||||
const content = candidates.length > 0 && isRecord(candidates[0]) && isRecord(candidates[0].content) ? candidates[0].content : null;
|
||||
const parts = content && Array.isArray(content.parts) ? content.parts : [];
|
||||
for (const part of parts) {
|
||||
@@ -164,5 +167,6 @@ export async function accumulateGeminiStream(messages: AsyncIterable<ProviderEve
|
||||
}
|
||||
}
|
||||
}
|
||||
requireFinished(finishReason, FINISHED_STOP_REASONS.gemini);
|
||||
return { text: textParts.join("").trim(), toolCalls, rawResponseJson: JSON.stringify(raw), ...(replayParts.length > 0 ? { replay: { format: "gemini", content: replayParts } } : {}) };
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* `convertResponsesTools` and the Responses streaming event shapes.
|
||||
*/
|
||||
import { isRecord, ProviderHttpError, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http";
|
||||
import { parseArguments, replayIn, type AgentTurnMessage, type AgentTurnResult, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
import { incompleteTurn, parseArguments, replayIn, type AgentTurnMessage, type AgentTurnResult, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn";
|
||||
|
||||
export function responsesToolDefs(tools: ToolDefinition[], strict: boolean | null = false): unknown[] {
|
||||
// `strict` must be a boolean or omitted. Passing `strict: null` (the Codex
|
||||
@@ -48,6 +48,7 @@ export function toResponsesInput(messages: AgentTurnMessage[]): unknown[] {
|
||||
|
||||
/** Parse a non-streaming Responses payload (`data.output[]`). */
|
||||
export function parseResponsesOutput(data: JsonRecord): { text: string; toolCalls: ToolCall[]; replay: AssistantReplay } {
|
||||
if (data.status !== "completed") throw incompleteTurn(incompleteReason(data));
|
||||
const output = Array.isArray(data.output) ? data.output : [];
|
||||
const textParts: string[] = [];
|
||||
const toolCalls: ToolCall[] = [];
|
||||
@@ -73,6 +74,11 @@ function responsesReplay(output: unknown[]): unknown[] {
|
||||
.map(({ id: _id, ...item }) => item);
|
||||
}
|
||||
|
||||
/** Why a Responses payload is not complete: its incomplete_details reason, else its status. */
|
||||
export function incompleteReason(response: JsonRecord): unknown {
|
||||
return isRecord(response.incomplete_details) && typeof response.incomplete_details.reason === "string" ? response.incomplete_details.reason : response.status;
|
||||
}
|
||||
|
||||
function eventType(message: ProviderEventStreamMessage): string | null {
|
||||
const type = message.data.type;
|
||||
return typeof type === "string" ? type : message.eventName;
|
||||
@@ -122,14 +128,13 @@ export async function accumulateResponsesStream(messages: AsyncIterable<Provider
|
||||
streamed.push(message.data.item);
|
||||
}
|
||||
if (type === "response.completed" || type === "response.done" || type === "response.incomplete") {
|
||||
const response = isRecord(message.data.response) ? message.data.response : null;
|
||||
const completionOutput = response && Array.isArray(response.output) ? response.output : [];
|
||||
const response = isRecord(message.data.response) ? message.data.response : {};
|
||||
// Codex completions may omit the status; the event type says it finished unless a status says otherwise.
|
||||
if (type === "response.incomplete" || (response.status !== undefined && response.status !== "completed")) throw incompleteTurn(incompleteReason(response) ?? "incomplete");
|
||||
const completionOutput = Array.isArray(response.output) ? response.output : [];
|
||||
const output = completionOutput.length > 0 ? completionOutput : streamed;
|
||||
return { ...parseResponsesOutput({ output }), rawResponseJson: JSON.stringify(raw) };
|
||||
return { ...parseResponsesOutput({ status: "completed", output }), rawResponseJson: JSON.stringify(raw) };
|
||||
}
|
||||
}
|
||||
if (streamed.length > 0) {
|
||||
return { ...parseResponsesOutput({ output: streamed }), rawResponseJson: JSON.stringify(raw) };
|
||||
}
|
||||
throw new Error("Responses stream ended before a completion event.");
|
||||
throw incompleteTurn(null);
|
||||
}
|
||||
Reference in new issue
Block a user