Files
AgentCompany/src/main/services/provider-tool-turns/turns.ts
T
ZacharyZhang-NY 0fa99d94a7 Chat-completions turns stream: reasoning models no longer die at the 60s header wall
Root cause of the stuck kimi-for-coding kickoff (task running forever, no
team spawning): runKimiCodeTurn posted stream:false, and a non-streaming
completion returns headers only after the whole generation — once the CEO's
context grew past ~150k chars, a reasoning turn needed >60s and provider-http's
header deadline aborted it ("Provider request timed out."), failing the run;
every heartbeat retry re-read the workspace, regrew the context, and died at
the same wall until the 3-run budget was gone.

Fix at the family level: accumulateChatStream (chat-format.ts) consumes the
standard OpenAI chunk deltas — content appends, indexed tool_call fragments,
reasoning_content keeping the 60s idle deadline fed — and a shared
runChatCompletionsStream drives makeChatCompletionsTurn (kimi, kimi-cn, xai,
groq, mistral, cerebras, openrouter) and runKimiCodeTurn.

Wire shape verified against MoonshotAI/kimi-cli (kosong streams by default)
and a live streamed probe of api.kimi.com/coding/v1 with the stored plan
credential: role opener, reasoning deltas, tool_call fragments (id+name then
argument text), finish_reason, [DONE] — all reassembled correctly.
verify:ci green including e2e.
2026-07-10 23:43:16 -04:00

284 lines
13 KiB
TypeScript

/**
* Per-backend tool-use turn functions. Each takes the normalized
* {@link ProviderToolTurnInput} (with a resolved credential) and returns an
* {@link AgentTurnResult}. Faithful ports of Pi's provider request handlers.
*/
import { release as osRelease, arch as osArch, platform as osPlatform } from "node:os";
import { postEventStream, postJson, type JsonRecord } from "../provider-http";
import type { AgentTurnMessage, AgentTurnResult, ProviderToolTurnInput, TurnCredential } from "../provider-tool-turn";
import { accumulateResponsesStream, parseResponsesOutput, responsesToolDefs, toResponsesInput } from "./responses-format";
import { anthropicToolDefs, parseAnthropicContent, toAnthropicMessages } from "./anthropic-format";
import { accumulateChatStream, chatToolDefs, parseChatChoice, toChatMessages } from "./chat-format";
import { accumulateGeminiStream, antigravityRequestBody, parseGeminiResponse, vertexRequestBody } from "./gemini-format";
import { bedrockToolConfig, parseBedrockOutput, toBedrockMessages } from "./bedrock-format";
import { signAwsRequest } from "./aws-sigv4";
import { getGcpAccessToken } from "./gcp-auth";
import { COPILOT_HEADERS, copilotBaseUrl } from "../oauth/github-copilot-flow";
import { KIMI_CODE_BASE_URL } from "../oauth/kimi-code-flow";
const ANTIGRAVITY_VERSION = "1.21.9";
const ANTIGRAVITY_ENDPOINT = "https://cloudcode-pa.googleapis.com";
const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
function requireApiKey(credential: TurnCredential, label: string): string {
if (!credential.apiKey) throw new Error(`${label} requires an API key.`);
return credential.apiKey;
}
function requireAccessToken(credential: TurnCredential, label: string): string {
if (!credential.accessToken) throw new Error(`${label} requires a signed-in access token.`);
return credential.accessToken;
}
function lastMessageIsUser(messages: AgentTurnMessage[]): boolean {
const last = messages[messages.length - 1];
return last?.role === "user";
}
// ---- OpenAI Responses (api key) ----
export async function runOpenAiResponsesTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const body: JsonRecord = {
model: input.model,
input: toResponsesInput(input.messages),
tools: responsesToolDefs(input.tools),
tool_choice: "auto",
store: false,
...(input.instructions ? { instructions: input.instructions } : {}),
};
const response = await postJson("https://api.openai.com/v1/responses", { authorization: `Bearer ${requireApiKey(input.credential, "OpenAI")}` }, body);
const parsed = parseResponsesOutput(response.data);
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
}
// ---- OpenAI Chat Completions, streamed (xAI, Groq, Mistral, Cerebras, OpenRouter, Kimi, Kimi CN) ----
/**
* Streaming is required, not an optimization: a non-streaming completion
* sends its response headers only after the WHOLE generation finishes, so a
* reasoning model that thinks past the 60s header deadline gets aborted with
* "Provider request timed out." even though it is working. Streamed turns
* emit chunks continuously and only a truly dead connection times out.
*/
async function runChatCompletionsStream(endpoint: string, headers: Record<string, string>, input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const body: JsonRecord = {
model: input.model,
messages: toChatMessages(input.instructions, input.messages),
tools: chatToolDefs(input.tools),
tool_choice: "auto",
stream: true,
};
const response = await postEventStream(endpoint, headers, body);
return accumulateChatStream(response.messages);
}
export function makeChatCompletionsTurn(endpoint: string, label: string): (input: ProviderToolTurnInput) => Promise<AgentTurnResult> {
return async (input) => runChatCompletionsStream(endpoint, { authorization: `Bearer ${requireApiKey(input.credential, label)}` }, input);
}
// ---- Anthropic Messages (api key) ----
export async function runAnthropicTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const body: JsonRecord = {
model: input.model,
max_tokens: 4096,
tools: anthropicToolDefs(input.tools),
messages: toAnthropicMessages(input.messages),
...(input.instructions ? { system: input.instructions } : {}),
};
const response = await postJson("https://api.anthropic.com/v1/messages", { "x-api-key": requireApiKey(input.credential, "Anthropic"), "anthropic-version": "2023-06-01" }, body);
const parsed = parseAnthropicContent(response.data);
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
}
// ---- Claude Pro/Max (OAuth subscription) ----
export async function runClaudeProTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const system: unknown[] = [
{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
...(input.instructions ? [{ type: "text", text: input.instructions }] : []),
];
const body: JsonRecord = {
model: input.model,
max_tokens: 4096,
tools: anthropicToolDefs(input.tools),
messages: toAnthropicMessages(input.messages),
system,
};
const response = await postJson("https://api.anthropic.com/v1/messages", {
authorization: `Bearer ${requireAccessToken(input.credential, "Claude Pro")}`,
"anthropic-version": "2023-06-01",
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20,fine-grained-tool-streaming-2025-05-14",
"anthropic-dangerous-direct-browser-access": "true",
"user-agent": "claude-cli/2.1.75",
"x-app": "cli",
}, body);
const parsed = parseAnthropicContent(response.data);
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
}
// ---- Kimi Code (OAuth plan, streamed chat completions at the coding endpoint) ----
export async function runKimiCodeTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const access = requireAccessToken(input.credential, "Kimi Code");
return runChatCompletionsStream(`${KIMI_CODE_BASE_URL}/chat/completions`, { authorization: `Bearer ${access}` }, input);
}
// ---- GPT account / OpenAI Codex (OAuth, SSE) ----
export async function runCodexTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const access = requireAccessToken(input.credential, "GPT account");
if (!input.credential.accountId) throw new Error("GPT account credential is missing its account id.");
const body: JsonRecord = {
model: input.model,
store: false,
stream: true,
...(input.instructions ? { instructions: input.instructions } : {}),
input: toResponsesInput(input.messages),
text: { verbosity: "medium" },
include: ["reasoning.encrypted_content"],
tool_choice: "auto",
parallel_tool_calls: true,
tools: responsesToolDefs(input.tools, null),
};
const response = await postEventStream(`${CODEX_BASE_URL}/codex/responses`, {
Authorization: `Bearer ${access}`,
"chatgpt-account-id": input.credential.accountId,
originator: "agentcompany",
"User-Agent": `agentcompany (${osPlatform()} ${osRelease()}; ${osArch()})`,
"OpenAI-Beta": "responses=experimental",
}, body);
return accumulateResponsesStream(response.messages);
}
// ---- GitHub Copilot (OAuth, openai-responses at the proxy) ----
export async function runCopilotTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const access = requireAccessToken(input.credential, "GitHub Copilot");
const baseUrl = copilotBaseUrl(access, input.credential.enterpriseUrl ?? undefined);
const body: JsonRecord = {
model: input.model,
input: toResponsesInput(input.messages),
tools: responsesToolDefs(input.tools),
tool_choice: "auto",
store: false,
};
const response = await postJson(`${baseUrl}/responses`, {
Authorization: `Bearer ${access}`,
...COPILOT_HEADERS,
"X-Initiator": lastMessageIsUser(input.messages) ? "user" : "agent",
"Openai-Intent": "conversation-edits",
}, body);
const parsed = parseResponsesOutput(response.data);
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
}
// ---- Custom OpenAI-compatible endpoint (base URL + key) ----
export async function runCustomOpenAiTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
if (!input.credential.baseUrl) throw new Error("Custom OpenAI-compatible provider is missing its base URL.");
const body: JsonRecord = {
model: input.model,
messages: toChatMessages(input.instructions, input.messages),
tools: chatToolDefs(input.tools),
tool_choice: "auto",
stream: false,
};
const response = await postJson(`${input.credential.baseUrl}/chat/completions`, { authorization: `Bearer ${requireApiKey(input.credential, "Custom OpenAI-compatible provider")}` }, body);
const parsed = parseChatChoice(response.data);
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
}
// ---- Custom Anthropic-compatible endpoint (base URL + key) ----
export async function runCustomAnthropicTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
if (!input.credential.baseUrl) throw new Error("Custom Anthropic-compatible provider is missing its base URL.");
const body: JsonRecord = {
model: input.model,
max_tokens: 4096,
tools: anthropicToolDefs(input.tools),
messages: toAnthropicMessages(input.messages),
...(input.instructions ? { system: input.instructions } : {}),
};
const response = await postJson(`${input.credential.baseUrl}/v1/messages`, { "x-api-key": requireApiKey(input.credential, "Custom Anthropic-compatible provider"), "anthropic-version": "2023-06-01" }, body);
const parsed = parseAnthropicContent(response.data);
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
}
// ---- Google Antigravity (OAuth, Gemini Code Assist, SSE) ----
export async function runAntigravityTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const access = requireAccessToken(input.credential, "Google Antigravity");
if (!input.credential.projectId) throw new Error("Antigravity credential is missing its project id.");
const requestId = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const body = antigravityRequestBody({
model: input.model,
projectId: input.credential.projectId,
instructions: input.instructions,
messages: input.messages,
tools: input.tools,
requestId,
});
const response = await postEventStream(`${ANTIGRAVITY_ENDPOINT}/v1internal:streamGenerateContent?alt=sse`, {
Authorization: `Bearer ${access}`,
"User-Agent": `antigravity/${ANTIGRAVITY_VERSION} darwin/arm64`,
}, body);
return accumulateGeminiStream(response.messages);
}
// ---- Azure OpenAI (Chat Completions at the resource deployment) ----
export async function runAzureTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const apiKey = requireApiKey(input.credential, "Azure OpenAI");
if (!input.credential.baseUrl) throw new Error("Azure OpenAI credential is missing its base URL.");
if (!input.credential.deployment) throw new Error("Azure OpenAI credential is missing its deployment name.");
const body: JsonRecord = {
model: input.credential.deployment,
messages: toChatMessages(input.instructions, input.messages),
tools: chatToolDefs(input.tools),
tool_choice: "auto",
stream: false,
};
const url = `${input.credential.baseUrl}/openai/deployments/${input.credential.deployment}/chat/completions?api-version=2024-10-21`;
const response = await postJson(url, { "api-key": apiKey }, body);
const parsed = parseChatChoice(response.data);
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
}
// ---- AWS Bedrock (Converse API, SigV4) ----
export async function runBedrockTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const { region, accessKeyId, secretAccessKey, sessionToken } = input.credential;
if (!region || !accessKeyId || !secretAccessKey) throw new Error("AWS Bedrock credential is incomplete.");
const converse: JsonRecord = {
messages: toBedrockMessages(input.messages),
inferenceConfig: { maxTokens: 4096 },
toolConfig: bedrockToolConfig(input.tools),
...(input.instructions ? { system: [{ text: input.instructions }] } : {}),
};
const bodyString = JSON.stringify(converse);
const signed = signAwsRequest({
method: "POST",
host: `bedrock-runtime.${region}.amazonaws.com`,
rawPath: `/model/${input.model}/converse`,
body: bodyString,
signer: { region, service: "bedrock", accessKeyId, secretAccessKey, sessionToken },
});
const response = await postJson(signed.url, signed.headers, converse);
const parsed = parseBedrockOutput(response.data);
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
}
// ---- Google Vertex AI (Gemini generateContent, service-account auth) ----
export async function runVertexTurn(input: ProviderToolTurnInput): Promise<AgentTurnResult> {
const { projectId, region, serviceAccountJson } = input.credential;
if (!projectId || !region || !serviceAccountJson) throw new Error("Vertex AI credential is incomplete.");
const token = await getGcpAccessToken(serviceAccountJson);
const body = vertexRequestBody({ model: input.model, instructions: input.instructions, messages: input.messages, tools: input.tools });
const url = `https://${region}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/google/models/${input.model}:generateContent`;
const response = await postJson(url, { authorization: `Bearer ${token}` }, body);
const parsed = parseGeminiResponse(response.data);
return { ...parsed, rawResponseJson: JSON.stringify(response.data) };
}