@openshain/agent 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,259 @@
1
+ import Anthropic, { type ClientOptions } from "@anthropic-ai/sdk";
2
+ import {
3
+ type AssistantPart,
4
+ type ErrorCode,
5
+ type ModelDescription,
6
+ type ModelMessage,
7
+ type ModelProvider,
8
+ type ModelRequest,
9
+ type ModelResponse,
10
+ type ModelUsage,
11
+ OpenshainError,
12
+ type RuntimeProviders,
13
+ type StopReason,
14
+ type ToolDefinition,
15
+ } from "@openshain/core";
16
+
17
+ export const ANTHROPIC_PROVIDER_ID = "anthropic";
18
+
19
+ /** Used when the request names no output limit. The config's limit normally does. */
20
+ const DEFAULT_MAX_TOKENS = 16_000;
21
+
22
+ type ModelSection = Parameters<RuntimeProviders["models"][string]>[0];
23
+
24
+ export interface AnthropicProviderOptions {
25
+ model: string;
26
+ apiKey: string;
27
+ baseUrl?: string;
28
+ /** Replaces the global fetch. Tests answer through it with recorded responses. */
29
+ fetch?: NonNullable<ClientOptions["fetch"]>;
30
+ }
31
+
32
+ /** Builds the provider from the model section of openshain.yaml. The key comes from the environment variable the config names. */
33
+ export function anthropicProvider(
34
+ model: ModelSection,
35
+ env: Record<string, string | undefined> = process.env,
36
+ ): AnthropicProvider {
37
+ const apiKey = env[model.apiKeyEnv];
38
+ if (!apiKey) {
39
+ throw new OpenshainError(
40
+ "config",
41
+ `environment variable ${model.apiKeyEnv} is not set; it should hold the Anthropic API key`,
42
+ );
43
+ }
44
+ return new AnthropicProvider({
45
+ model: model.model,
46
+ apiKey,
47
+ ...(model.baseUrl && { baseUrl: model.baseUrl }),
48
+ });
49
+ }
50
+
51
+ /** Claude through the Messages API. Thinking blocks travel as opaque parts and go back unchanged. */
52
+ export class AnthropicProvider implements ModelProvider {
53
+ readonly id = ANTHROPIC_PROVIDER_ID;
54
+ private readonly client: Anthropic;
55
+ private readonly model: string;
56
+
57
+ constructor(options: AnthropicProviderOptions) {
58
+ this.model = options.model;
59
+ this.client = new Anthropic({
60
+ apiKey: options.apiKey.trim(),
61
+ ...(options.baseUrl && { baseURL: baseUrlRoot(options.baseUrl) }),
62
+ ...(options.fetch && { fetch: options.fetch }),
63
+ });
64
+ }
65
+
66
+ describe(): ModelDescription {
67
+ return { provider: ANTHROPIC_PROVIDER_ID, model: this.model, capabilities: { tools: true } };
68
+ }
69
+
70
+ async generate(request: ModelRequest, signal?: AbortSignal): Promise<ModelResponse> {
71
+ const params = toParams(request, this.model);
72
+ try {
73
+ const message = await this.client.messages.create(params, { ...(signal && { signal }) });
74
+ return fromMessage(message);
75
+ } catch (err) {
76
+ throw toError(err);
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The request as the Messages API takes it. providerOptions land on the body as they are, so
83
+ * thinking, output_config and cache_control can be set or overridden from the config; `effort`
84
+ * alone is a shorthand for output_config.effort. The model, the limit, the system prompt, the
85
+ * tools, the messages and the choice not to stream come from the runtime and cannot be
86
+ * overridden. A cache breakpoint goes on the last block of the last message that will be sent
87
+ * unchanged next turn (`stableMessages`), so the next turn reads that prefix from the cache.
88
+ */
89
+ export function toParams(
90
+ request: ModelRequest,
91
+ model: string,
92
+ ): Anthropic.MessageCreateParamsNonStreaming {
93
+ const {
94
+ effort,
95
+ output_config,
96
+ model: _model,
97
+ max_tokens: _maxTokens,
98
+ system: _system,
99
+ tools: _tools,
100
+ messages: _messages,
101
+ stream: _stream,
102
+ ...extra
103
+ } = request.providerOptions ?? {};
104
+ const outputConfig = {
105
+ ...(output_config as Record<string, unknown> | undefined),
106
+ ...(effort !== undefined && { effort }),
107
+ };
108
+ const messages = request.messages.map(toMessage);
109
+ anchorCache(messages, request.stableMessages ?? 0);
110
+ return {
111
+ ...extra,
112
+ ...(Object.keys(outputConfig).length > 0 && { output_config: outputConfig }),
113
+ model,
114
+ stream: false,
115
+ max_tokens: request.maxOutputTokens ?? DEFAULT_MAX_TOKENS,
116
+ ...(request.system && { system: request.system }),
117
+ ...(request.tools && request.tools.length > 0 && { tools: request.tools.map(toTool) }),
118
+ messages,
119
+ } as Anthropic.MessageCreateParamsNonStreaming;
120
+ }
121
+
122
+ /** Marks the last block of the last stable message, the point up to which the next turn is identical. */
123
+ function anchorCache(messages: Anthropic.MessageParam[], stable: number): void {
124
+ const anchor = messages[stable - 1];
125
+ if (!anchor || !Array.isArray(anchor.content)) return;
126
+ const last = anchor.content.at(-1);
127
+ if (last && (last.type === "text" || last.type === "tool_result" || last.type === "tool_use")) {
128
+ last.cache_control = { type: "ephemeral" };
129
+ }
130
+ }
131
+
132
+ /** The SDK appends /v1/messages itself, so a base URL that ends in /v1 loses that part. */
133
+ export function baseUrlRoot(baseUrl: string): string {
134
+ return baseUrl.replace(/\/v1\/?$/, "");
135
+ }
136
+
137
+ function toTool(tool: ToolDefinition): Anthropic.Tool {
138
+ return {
139
+ name: tool.name,
140
+ description: tool.description,
141
+ input_schema: tool.inputSchema as Anthropic.Tool.InputSchema,
142
+ };
143
+ }
144
+
145
+ function toMessage(message: ModelMessage): Anthropic.MessageParam {
146
+ const content: Anthropic.ContentBlockParam[] = [];
147
+ if (message.role === "user") {
148
+ for (const part of message.content) {
149
+ if (part.type === "text") {
150
+ if (part.text !== "") content.push({ type: "text", text: part.text });
151
+ } else {
152
+ content.push({
153
+ type: "tool_result",
154
+ tool_use_id: part.callId,
155
+ content: part.content,
156
+ ...(part.isError && { is_error: true }),
157
+ });
158
+ }
159
+ }
160
+ return { role: "user", content };
161
+ }
162
+ for (const part of message.content) {
163
+ if (part.type === "text") {
164
+ if (part.text !== "") content.push({ type: "text", text: part.text });
165
+ } else if (part.type === "tool_call") {
166
+ content.push({ type: "tool_use", id: part.id, name: part.name, input: part.input });
167
+ } else if (part.provider === ANTHROPIC_PROVIDER_ID) {
168
+ content.push(part.data as Anthropic.ContentBlockParam);
169
+ }
170
+ }
171
+ if (content.length === 0) {
172
+ throw new OpenshainError(
173
+ "invalid_response",
174
+ "an assistant message has nothing this provider can send; it may belong to another provider",
175
+ );
176
+ }
177
+ return { role: "assistant", content };
178
+ }
179
+
180
+ /**
181
+ * The response in the contract's terms. Every block that is not text or a tool call is kept
182
+ * opaque. A refusal's explanation becomes text, so the log says why. A response whose shape is
183
+ * not a message is an invalid response.
184
+ */
185
+ export function fromMessage(message: Anthropic.Message): ModelResponse {
186
+ if (!message || !Array.isArray(message.content)) {
187
+ throw new OpenshainError("invalid_response", "the response is not a message");
188
+ }
189
+ const content: AssistantPart[] = [];
190
+ for (const block of message.content) {
191
+ if (block.type === "text") content.push({ type: "text", text: block.text });
192
+ else if (block.type === "tool_use") {
193
+ content.push({ type: "tool_call", id: block.id, name: block.name, input: block.input });
194
+ } else content.push({ type: "opaque", provider: ANTHROPIC_PROVIDER_ID, data: block });
195
+ }
196
+ const explanation = message.stop_details?.explanation;
197
+ if (message.stop_reason === "refusal" && explanation) {
198
+ content.push({ type: "text", text: explanation });
199
+ }
200
+ return {
201
+ message: { role: "assistant", content },
202
+ stopReason: toStopReason(
203
+ message.stop_reason,
204
+ content.some((part) => part.type === "tool_call"),
205
+ ),
206
+ usage: toUsage(message.usage),
207
+ raw: message,
208
+ };
209
+ }
210
+
211
+ const STOP_REASONS: Record<string, StopReason> = {
212
+ end_turn: "end_turn",
213
+ stop_sequence: "end_turn",
214
+ tool_use: "tool_call",
215
+ max_tokens: "max_tokens",
216
+ refusal: "refusal",
217
+ };
218
+
219
+ /** Some gateways say end_turn with tool_use blocks present; the blocks decide. */
220
+ function toStopReason(reason: string | null, hasToolUse: boolean): StopReason {
221
+ if (hasToolUse && reason !== "max_tokens") return "tool_call";
222
+ return (reason && STOP_REASONS[reason]) || "other";
223
+ }
224
+
225
+ function toUsage(usage: Anthropic.Usage | undefined): ModelUsage {
226
+ const thinking = usage?.output_tokens_details?.thinking_tokens;
227
+ const read = usage?.cache_read_input_tokens ?? 0;
228
+ const written = usage?.cache_creation_input_tokens ?? 0;
229
+ return {
230
+ inputTokens: (usage?.input_tokens ?? 0) + read + written,
231
+ outputTokens: usage?.output_tokens ?? 0,
232
+ ...(usage?.cache_read_input_tokens != null && {
233
+ cachedInputTokens: usage.cache_read_input_tokens,
234
+ }),
235
+ ...(usage?.cache_creation_input_tokens != null && {
236
+ cacheWriteTokens: usage.cache_creation_input_tokens,
237
+ }),
238
+ ...(thinking != null && { reasoningTokens: thinking }),
239
+ };
240
+ }
241
+
242
+ /** The SDK's typed errors as the codes the runtime records. Anything else means the response could not be read. */
243
+ function toError(err: unknown): OpenshainError {
244
+ if (err instanceof OpenshainError) return err;
245
+ if (err instanceof Anthropic.APIUserAbortError) return wrap("network", err);
246
+ if (err instanceof Anthropic.AuthenticationError) return wrap("auth", err);
247
+ if (err instanceof Anthropic.PermissionDeniedError) return wrap("auth", err);
248
+ if (err instanceof Anthropic.RateLimitError) return wrap("rate_limit", err);
249
+ if (err instanceof Anthropic.BadRequestError) return wrap("config", err);
250
+ if (err instanceof Anthropic.NotFoundError) return wrap("config", err);
251
+ if (err instanceof Anthropic.APIConnectionError) return wrap("network", err);
252
+ if (err instanceof Anthropic.InternalServerError) return wrap("network", err);
253
+ if (err instanceof Anthropic.APIError) return wrap("invalid_response", err);
254
+ return wrap("invalid_response", err instanceof Error ? err : new Error(String(err)));
255
+ }
256
+
257
+ function wrap(code: ErrorCode, err: Error): OpenshainError {
258
+ return new OpenshainError(code, `Anthropic: ${err.message}`, { cause: err });
259
+ }
@@ -0,0 +1,299 @@
1
+ import {
2
+ type AssistantPart,
3
+ type ErrorCode,
4
+ type ModelDescription,
5
+ type ModelMessage,
6
+ type ModelProvider,
7
+ type ModelRequest,
8
+ type ModelResponse,
9
+ type ModelUsage,
10
+ OpenshainError,
11
+ type RuntimeProviders,
12
+ type StopReason,
13
+ type ToolDefinition,
14
+ } from "@openshain/core";
15
+ import OpenAI, { type ClientOptions } from "openai";
16
+ import type {
17
+ ChatCompletion,
18
+ ChatCompletionCreateParamsNonStreaming,
19
+ ChatCompletionMessageParam,
20
+ ChatCompletionTool,
21
+ } from "openai/resources/chat/completions";
22
+
23
+ export const OPENAI_COMPATIBLE_PROVIDER_ID = "openai-compatible";
24
+
25
+ /** Used when the request names no output limit. The config's limit normally does. */
26
+ const DEFAULT_MAX_TOKENS = 16_000;
27
+
28
+ type ModelSection = Parameters<RuntimeProviders["models"][string]>[0];
29
+
30
+ export interface OpenAICompatibleProviderOptions {
31
+ model: string;
32
+ apiKey: string;
33
+ /** The API root including its version segment, for example http://localhost:11434/v1. */
34
+ baseUrl?: string;
35
+ /** False for an endpoint that cannot call tools. The runtime then refuses to start. */
36
+ tools?: boolean;
37
+ /** Replaces the global fetch. Tests answer through it with recorded responses. */
38
+ fetch?: NonNullable<ClientOptions["fetch"]>;
39
+ }
40
+
41
+ /**
42
+ * Builds the provider from the model section of openshain.yaml. The key comes from the environment
43
+ * variable the config names; `options: { tools: false }` declares an endpoint without tool support.
44
+ */
45
+ export function openaiCompatibleProvider(
46
+ model: ModelSection,
47
+ env: Record<string, string | undefined> = process.env,
48
+ ): OpenAICompatibleProvider {
49
+ const apiKey = env[model.apiKeyEnv];
50
+ if (!apiKey) {
51
+ throw new OpenshainError(
52
+ "config",
53
+ `environment variable ${model.apiKeyEnv} is not set; it should hold the API key`,
54
+ );
55
+ }
56
+ return new OpenAICompatibleProvider({
57
+ model: model.model,
58
+ apiKey,
59
+ ...(model.baseUrl && { baseUrl: model.baseUrl }),
60
+ ...(model.options?.tools === false && { tools: false }),
61
+ });
62
+ }
63
+
64
+ /** Any chat completions endpoint with function calling: OpenAI, a local server, or another vendor's compatible API. */
65
+ export class OpenAICompatibleProvider implements ModelProvider {
66
+ readonly id = OPENAI_COMPATIBLE_PROVIDER_ID;
67
+ private readonly client: OpenAI;
68
+ private readonly model: string;
69
+ private readonly tools: boolean;
70
+
71
+ constructor(options: OpenAICompatibleProviderOptions) {
72
+ this.model = options.model;
73
+ this.tools = options.tools ?? true;
74
+ this.client = new OpenAI({
75
+ apiKey: options.apiKey.trim(),
76
+ ...(options.baseUrl && { baseURL: options.baseUrl }),
77
+ ...(options.fetch && { fetch: options.fetch }),
78
+ });
79
+ }
80
+
81
+ describe(): ModelDescription {
82
+ return {
83
+ provider: OPENAI_COMPATIBLE_PROVIDER_ID,
84
+ model: this.model,
85
+ capabilities: { tools: this.tools },
86
+ };
87
+ }
88
+
89
+ async generate(request: ModelRequest, signal?: AbortSignal): Promise<ModelResponse> {
90
+ const params = toParams(request, this.model);
91
+ try {
92
+ const completion = await this.client.chat.completions.create(params, {
93
+ ...(signal && { signal }),
94
+ });
95
+ return fromCompletion(completion);
96
+ } catch (err) {
97
+ throw toError(err);
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * The request as chat completions take it. providerOptions land on the body as they are
104
+ * (reasoning_effort, temperature, and so on); the model, the limit, the messages, the tools and
105
+ * the choice not to stream come from the runtime. The limit is sent as max_completion_tokens; a
106
+ * max_tokens in the options only asks for that older name, which some servers still expect,
107
+ * and its value is ignored. A `tools` flag in the options is the provider's, not the request's.
108
+ */
109
+ export function toParams(
110
+ request: ModelRequest,
111
+ model: string,
112
+ ): ChatCompletionCreateParamsNonStreaming {
113
+ const {
114
+ tools: _flag,
115
+ model: _model,
116
+ messages: _messages,
117
+ stream: _stream,
118
+ max_completion_tokens: _limit,
119
+ max_tokens: legacy,
120
+ ...extra
121
+ } = request.providerOptions ?? {};
122
+ const limit = request.maxOutputTokens ?? DEFAULT_MAX_TOKENS;
123
+ const messages: ChatCompletionMessageParam[] = [];
124
+ if (request.system) messages.push({ role: "system", content: request.system });
125
+ for (const message of request.messages) messages.push(...toMessages(message));
126
+ return {
127
+ ...extra,
128
+ model,
129
+ stream: false,
130
+ ...(legacy !== undefined ? { max_tokens: limit } : { max_completion_tokens: limit }),
131
+ messages,
132
+ ...(request.tools && request.tools.length > 0 && { tools: request.tools.map(toTool) }),
133
+ } as ChatCompletionCreateParamsNonStreaming;
134
+ }
135
+
136
+ function toTool(tool: ToolDefinition): ChatCompletionTool {
137
+ return {
138
+ type: "function",
139
+ function: { name: tool.name, description: tool.description, parameters: tool.inputSchema },
140
+ };
141
+ }
142
+
143
+ /** One contract message becomes one or more chat messages: every tool result is a message of its own. */
144
+ function toMessages(message: ModelMessage): ChatCompletionMessageParam[] {
145
+ if (message.role === "user") {
146
+ const out: ChatCompletionMessageParam[] = [];
147
+ const texts: string[] = [];
148
+ for (const part of message.content) {
149
+ if (part.type === "text") {
150
+ if (part.text !== "") texts.push(part.text);
151
+ } else {
152
+ out.push({ role: "tool", tool_call_id: part.callId, content: part.content });
153
+ }
154
+ }
155
+ if (texts.length > 0) out.push({ role: "user", content: texts.join("\n") });
156
+ return out;
157
+ }
158
+ const texts: string[] = [];
159
+ const toolCalls: NonNullable<
160
+ Extract<ChatCompletionMessageParam, { role: "assistant" }>["tool_calls"]
161
+ > = [];
162
+ let opaque: Record<string, unknown> = {};
163
+ for (const part of message.content) {
164
+ if (part.type === "text") {
165
+ if (part.text !== "") texts.push(part.text);
166
+ } else if (part.type === "tool_call") {
167
+ toolCalls.push({
168
+ id: part.id,
169
+ type: "function",
170
+ function: { name: part.name, arguments: JSON.stringify(part.input ?? {}) },
171
+ });
172
+ } else if (part.provider === OPENAI_COMPATIBLE_PROVIDER_ID) {
173
+ opaque = { ...opaque, ...(part.data as Record<string, unknown>) };
174
+ }
175
+ }
176
+ const { unsupported_tool_calls: _unsupported, ...sent } = opaque;
177
+ if (texts.length === 0 && toolCalls.length === 0 && Object.keys(sent).length === 0) {
178
+ throw new OpenshainError(
179
+ "invalid_response",
180
+ "an assistant message has nothing this provider can send; it may belong to another provider",
181
+ );
182
+ }
183
+ return [
184
+ {
185
+ ...sent,
186
+ role: "assistant",
187
+ content: texts.length > 0 ? texts.join("\n") : null,
188
+ ...(toolCalls.length > 0 && { tool_calls: toolCalls }),
189
+ },
190
+ ];
191
+ }
192
+
193
+ /**
194
+ * The completion in the contract's terms. Fields of the assistant message beyond content and
195
+ * tool calls, such as a server's reasoning, are kept opaque and go back with the message. Tool
196
+ * calls that are not function calls are kept opaque too, but never sent back. A refusal's text
197
+ * becomes text, so the log says why. A response whose shape is not a completion is an invalid
198
+ * response.
199
+ */
200
+ export function fromCompletion(completion: ChatCompletion): ModelResponse {
201
+ const choice = completion?.choices?.[0];
202
+ if (!choice?.message) {
203
+ throw new OpenshainError("invalid_response", "the completion has no message");
204
+ }
205
+ const { role: _role, content: rawContent, tool_calls, refusal, ...rest } = choice.message;
206
+ const content: AssistantPart[] = [];
207
+ const text = joinContent(rawContent);
208
+ if (text) content.push({ type: "text", text });
209
+ if (refusal) content.push({ type: "text", text: refusal });
210
+ const calls = (tool_calls ?? []).filter((call) => call.type === "function");
211
+ for (const call of calls) {
212
+ content.push({
213
+ type: "tool_call",
214
+ id: call.id,
215
+ name: call.function.name,
216
+ input: parseArguments(call.function.arguments),
217
+ });
218
+ }
219
+ const unsupported = (tool_calls ?? []).filter((call) => call.type !== "function");
220
+ const opaque: Record<string, unknown> = Object.fromEntries(
221
+ Object.entries(rest).filter(([key, value]) => key !== "annotations" && value != null),
222
+ );
223
+ if (unsupported.length > 0) opaque.unsupported_tool_calls = unsupported;
224
+ if (Object.keys(opaque).length > 0) {
225
+ content.push({ type: "opaque", provider: OPENAI_COMPATIBLE_PROVIDER_ID, data: opaque });
226
+ }
227
+ return {
228
+ message: { role: "assistant", content },
229
+ stopReason: toStopReason(choice.finish_reason, calls.length > 0),
230
+ usage: toUsage(completion.usage),
231
+ raw: completion,
232
+ };
233
+ }
234
+
235
+ /** Content is a string, but some servers return text parts; those are joined. */
236
+ function joinContent(content: unknown): string {
237
+ if (typeof content === "string") return content;
238
+ if (!Array.isArray(content)) return "";
239
+ return content
240
+ .map((part) =>
241
+ part && typeof part === "object" ? (part as { text?: unknown }).text : undefined,
242
+ )
243
+ .filter((text): text is string => typeof text === "string")
244
+ .join("");
245
+ }
246
+
247
+ /** Tool arguments arrive as a JSON string. One that does not parse is passed on as it is, so the schema check reports it. */
248
+ function parseArguments(args: string): unknown {
249
+ if (args.trim() === "") return {};
250
+ try {
251
+ return JSON.parse(args);
252
+ } catch {
253
+ return args;
254
+ }
255
+ }
256
+
257
+ const STOP_REASONS: Record<string, StopReason> = {
258
+ stop: "end_turn",
259
+ tool_calls: "tool_call",
260
+ length: "max_tokens",
261
+ content_filter: "refusal",
262
+ };
263
+
264
+ /** Some servers say `stop` even when they made tool calls; the function calls decide either way. */
265
+ function toStopReason(reason: string | null, hasToolCalls: boolean): StopReason {
266
+ if (hasToolCalls && reason !== "length") return "tool_call";
267
+ if (reason === "tool_calls" && !hasToolCalls) return "other";
268
+ return (reason && STOP_REASONS[reason]) || "other";
269
+ }
270
+
271
+ function toUsage(usage: ChatCompletion["usage"]): ModelUsage {
272
+ const cached = usage?.prompt_tokens_details?.cached_tokens;
273
+ const reasoning = usage?.completion_tokens_details?.reasoning_tokens;
274
+ return {
275
+ inputTokens: usage?.prompt_tokens ?? 0,
276
+ outputTokens: usage?.completion_tokens ?? 0,
277
+ ...(cached != null && { cachedInputTokens: cached }),
278
+ ...(reasoning != null && { reasoningTokens: reasoning }),
279
+ };
280
+ }
281
+
282
+ /** The SDK's typed errors as the codes the runtime records. Anything else means the response could not be read. */
283
+ function toError(err: unknown): OpenshainError {
284
+ if (err instanceof OpenshainError) return err;
285
+ if (err instanceof OpenAI.APIUserAbortError) return wrap("network", err);
286
+ if (err instanceof OpenAI.AuthenticationError) return wrap("auth", err);
287
+ if (err instanceof OpenAI.PermissionDeniedError) return wrap("auth", err);
288
+ if (err instanceof OpenAI.RateLimitError) return wrap("rate_limit", err);
289
+ if (err instanceof OpenAI.BadRequestError) return wrap("config", err);
290
+ if (err instanceof OpenAI.NotFoundError) return wrap("config", err);
291
+ if (err instanceof OpenAI.APIConnectionError) return wrap("network", err);
292
+ if (err instanceof OpenAI.InternalServerError) return wrap("network", err);
293
+ if (err instanceof OpenAI.APIError) return wrap("invalid_response", err);
294
+ return wrap("invalid_response", err instanceof Error ? err : new Error(String(err)));
295
+ }
296
+
297
+ function wrap(code: ErrorCode, err: Error): OpenshainError {
298
+ return new OpenshainError(code, `chat completions: ${err.message}`, { cause: err });
299
+ }