Skip to content
Merged
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
80 changes: 74 additions & 6 deletions src/client/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
import { convertToAnthropicRequest, parseAnthropicStream } from './anthropic';
import { createHttpError, formatRequestError, GLMRequestError, normalizeRequestError } from './error';
import { analyzeContextOverflow } from './error/overflow-retry';
import { convertToResponsesRequest, parseResponsesStream } from './responses';
import { trackRateLimitHeaders, waitIfRateLimited } from './rate-limit';

// Retry configuration for transient HTTP errors (429, 502, 503, 504).
Expand Down Expand Up @@ -67,8 +68,8 @@ function isOpencodeGateway(baseUrl: string): boolean {
* Lightweight SSE-streaming GLM API client.
* No external dependencies — uses Node's built-in fetch.
*
* Supports both OpenAI-compatible (`/chat/completions`) and
* Anthropic-compatible (`/v1/messages`) protocols.
* Supports OpenAI-compatible (`/chat/completions`), Anthropic-compatible
* (`/v1/messages`), and OpenAI Responses (`/responses`) protocols.
*/
export class GLMClient {
constructor(
Expand All @@ -95,10 +96,15 @@ export class GLMClient {
callbacks: StreamCallbacks,
cancellationToken?: CancellationToken,
): Promise<void> {
const dispatch = (req: GLMRequest) =>
this.protocol === 'anthropic'
? this.streamAnthropicCompletion(req, callbacks, cancellationToken)
: this.streamOpenAIChatCompletion(req, callbacks, cancellationToken);
const dispatch = (req: GLMRequest) => {
if (this.protocol === 'anthropic') {
return this.streamAnthropicCompletion(req, callbacks, cancellationToken);
}
if (this.protocol === 'responses') {
return this.streamResponsesCompletion(req, callbacks, cancellationToken);
}
return this.streamOpenAIChatCompletion(req, callbacks, cancellationToken);
};

// Phase 1: retry with backoff for transient rate-limit / availability errors.
let lastError: unknown;
Expand Down Expand Up @@ -393,6 +399,68 @@ export class GLMClient {
}
}

/**
* Stream using OpenAI Responses `/responses` endpoint.
*/
private async streamResponsesCompletion(
request: GLMRequest,
callbacks: StreamCallbacks,
cancellationToken?: CancellationToken,
): Promise<void> {
const controller = new AbortController();
const cancelListener = cancellationToken?.onCancellationRequested(() => {
controller.abort();
});
if (cancellationToken?.isCancellationRequested) {
cancelListener?.dispose();
controller.abort();
return;
}

try {
const responsesRequest = convertToResponsesRequest(request);

await waitIfRateLimited(this.baseUrl);
const response = await fetch(`${this.baseUrl}/responses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
...(isOpencodeGateway(this.baseUrl) ? getOpenCodeGatewayHeaders() : {}),
},
body: safeStringify(responsesRequest),
signal: controller.signal,
});

if (!response.ok) {
throw await createHttpError(response, {
baseUrl: this.baseUrl,
request,
});
}

trackRateLimitHeaders(this.baseUrl, response.headers);

if (!response.body) {
throw new Error('No response body received');
}

const reader = response.body.getReader();
try {
await parseResponsesStream(reader, callbacks);
} finally {
await reader.cancel().catch((err) => {
if (!isAbortError(err)) {
logger.warn('Error cancelling Responses stream reader:', err);
}
});
}
} finally {
cancelListener?.dispose();
controller.abort();
}
}

/**
* Stream using Anthropic-compatible `/v1/messages` endpoint.
*/
Expand Down
175 changes: 175 additions & 0 deletions src/client/responses/convert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import type { GLMMessage, GLMRequest, GLMTool } from '../../types';

interface ResponsesTextPart {
type: 'input_text' | 'output_text';
text: string;
}

interface ResponsesMessageItem {
type: 'message';
role: 'user' | 'assistant';
content: ResponsesTextPart[];
}

interface ResponsesFunctionCallItem {
type: 'function_call';
call_id: string;
name: string;
arguments: string;
}

interface ResponsesFunctionCallOutputItem {
type: 'function_call_output';
call_id: string;
output: string;
}

type ResponsesInputItem =
| ResponsesMessageItem
| ResponsesFunctionCallItem
| ResponsesFunctionCallOutputItem;

interface ResponsesFunctionTool {
type: 'function';
name: string;
description?: string;
parameters: Record<string, unknown>;
}

export interface ResponsesRequest {
model: string;
input: ResponsesInputItem[];
instructions?: string;
tools?: ResponsesFunctionTool[];
tool_choice?: 'none' | 'auto' | 'required';
stream: boolean;
max_output_tokens?: number;
temperature?: number;
top_p?: number;
reasoning?: { effort: 'low' | 'medium' | 'high' };
}

/**
* Convert an internal GLMRequest to the OpenAI Responses API format.
*/
export function convertToResponsesRequest(request: GLMRequest): ResponsesRequest {
const instructions = extractInstructions(request.messages);
const input = convertInput(request.messages);
const tools = request.tools?.length ? request.tools.map(convertTool) : undefined;

const responsesRequest: ResponsesRequest = {
model: request.model,
input,
stream: request.stream,
};

if (instructions) {
responsesRequest.instructions = instructions;
}
if (tools) {
responsesRequest.tools = tools;
}
if (request.tool_choice) {
responsesRequest.tool_choice = request.tool_choice;
}
if (request.max_tokens !== undefined) {
responsesRequest.max_output_tokens = request.max_tokens;
}
if (request.temperature !== undefined) {
responsesRequest.temperature = request.temperature;
}
if (request.top_p !== undefined) {
responsesRequest.top_p = request.top_p;
}
if (request.thinking?.type === 'enabled') {
responsesRequest.reasoning = { effort: mapReasoningEffort(request.reasoning_effort) };
}

return responsesRequest;
}

function extractInstructions(messages: GLMMessage[]): string | undefined {
const parts: string[] = [];
for (const msg of messages) {
if (msg.role === 'system') {
parts.push(msg.content);
}
}
if (parts.length === 0) {
return undefined;
}
return parts.join('\n\n');
}

function convertInput(messages: GLMMessage[]): ResponsesInputItem[] {
const input: ResponsesInputItem[] = [];

for (const msg of messages) {
if (msg.role === 'system') {
continue;
}

if (msg.role === 'tool') {
if (!msg.tool_call_id) {
throw new Error('Tool message is missing tool_call_id');
}
input.push({
type: 'function_call_output',
call_id: msg.tool_call_id,
output: msg.content,
});
continue;
}

if (msg.role === 'assistant' && msg.tool_calls && msg.tool_calls.length > 0) {
if (msg.content) {
input.push(createAssistantMessage(msg.content));
}
for (const tc of msg.tool_calls) {
input.push({
type: 'function_call',
call_id: tc.id,
name: tc.function.name,
arguments: tc.function.arguments,
});
}
continue;
}

if (msg.role === 'user') {
input.push({
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: msg.content }],
});
continue;
}

if (msg.role === 'assistant') {
input.push(createAssistantMessage(msg.content));
}
}

return input;
}

function createAssistantMessage(content: string): ResponsesMessageItem {
return {
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: content }],
};
}

function convertTool(tool: GLMTool): ResponsesFunctionTool {
return {
type: 'function',
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters ?? { type: 'object', properties: {} },
};
}

function mapReasoningEffort(effort: GLMRequest['reasoning_effort']): 'low' | 'medium' | 'high' {
return effort === 'max' ? 'high' : 'medium';
}
3 changes: 3 additions & 0 deletions src/client/responses/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { convertToResponsesRequest } from './convert';
export type { ResponsesRequest } from './convert';
export { parseResponsesStream } from './stream';
Loading
Loading