@effect-uai/chat-completions 0.11.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Betalyra Sociedade Unipessoal Lda.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @effect-uai/chat-completions
2
+
3
+ Reusable OpenAI Chat Completions provider for [`@effect-uai/core`](https://www.npmjs.com/package/@effect-uai/core).
4
+
5
+ Implements the `LanguageModel` contract against the `POST /chat/completions`
6
+ wire dialect (SSE streaming, `messages[]`, `tool_calls`, `response_format`).
7
+ Point it at any compatible endpoint by setting `baseUrl`: OpenRouter, Requesty,
8
+ Groq, Together, or a self-hosted gateway.
9
+
10
+ > Prefer [`@effect-uai/responses`](https://www.npmjs.com/package/@effect-uai/responses)
11
+ > when the endpoint speaks the Responses protocol. Reach for chat-completions
12
+ > only when it does not: this dialect has no typed reasoning items and no
13
+ > server-side state (`store` / `previousResponseId`).
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ pnpm add @effect-uai/chat-completions @effect-uai/core effect
19
+ ```
20
+
21
+ ESM-only. Requires `effect@4.x` and `@effect-uai/core` as peers.
22
+
23
+ ## Usage
24
+
25
+ ```ts
26
+ import { Config, Effect, Layer } from "effect"
27
+ import { FetchHttpClient } from "effect/unstable/http"
28
+ import { layer as chatLayer } from "@effect-uai/chat-completions/ChatCompletions"
29
+
30
+ const provider = Layer.unwrap(
31
+ Effect.gen(function* () {
32
+ const apiKey = yield* Config.redacted("LLM_API_KEY")
33
+ return chatLayer({
34
+ apiKey,
35
+ baseUrl: "https://openrouter.ai/api/v1",
36
+ provider: "openrouter",
37
+ })
38
+ }),
39
+ )
40
+
41
+ const layer = Layer.provide(provider, FetchHttpClient.layer)
42
+ ```
43
+
44
+ The layer registers the generic `LanguageModel` tag, so provider-agnostic loop
45
+ bodies run against it unchanged. `model` is a plain string: these gateways ship
46
+ hundreds of models, so there is no typed union.
47
+
48
+ `ChatConfig` also accepts `path`, `authHeader`, `extraHeaders`, and `extraBody`
49
+ for endpoints that diverge from the OpenRouter defaults.
50
+
51
+ ## Docs
52
+
53
+ <https://effect-uai.betalyra.com/providers/>
54
+
55
+ ## License
56
+
57
+ MIT
@@ -0,0 +1,28 @@
1
+ import { Effect, Layer, Redacted } from "effect";
2
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3
+ import { CommonRequest, LanguageModel, LanguageModelService } from "@effect-uai/core/LanguageModel";
4
+
5
+ //#region src/ChatCompletions.d.ts
6
+ declare namespace ChatCompletions_d_exports {
7
+ export { ChatConfig, layer, make };
8
+ }
9
+ type ChatConfig = {
10
+ readonly apiKey: Redacted.Redacted; /** Provider base URL, e.g. `https://openrouter.ai/api/v1`. No trailing slash. */
11
+ readonly baseUrl: string; /** Name used to tag `AiError`s from this provider. */
12
+ readonly provider: string; /** Request path appended to `baseUrl`. Defaults to `/chat/completions`. */
13
+ readonly path?: string;
14
+ /**
15
+ * Override the auth header. Defaults to `Authorization: Bearer <apiKey>`.
16
+ * Supply this for gateways that authenticate differently (e.g. Azure `api-key`).
17
+ */
18
+ readonly authHeader?: (request: HttpClientRequest.HttpClientRequest) => HttpClientRequest.HttpClientRequest; /** Extra headers merged into every request (e.g. OpenRouter `HTTP-Referer`). */
19
+ readonly extraHeaders?: Record<string, string>; /** Provider-specific request-body fields merged into the wire body. */
20
+ readonly extraBody?: (request: CommonRequest) => Record<string, unknown>;
21
+ };
22
+ /** Build a generic chat-completions `LanguageModelService`. */
23
+ declare const make: (cfg: ChatConfig) => Effect.Effect<LanguageModelService, never, HttpClient.HttpClient>;
24
+ /** Layer registering the generic `LanguageModel` tag for a compatible gateway. */
25
+ declare const layer: (cfg: ChatConfig) => Layer.Layer<LanguageModel, never, HttpClient.HttpClient>;
26
+ //#endregion
27
+ export { ChatConfig, layer, make, ChatCompletions_d_exports as t };
28
+ //# sourceMappingURL=ChatCompletions.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ChatCompletions.d.mts","names":[],"sources":["../src/ChatCompletions.ts"],"mappings":";;;;;;;;KA8BY,UAAA;EAAA,SACD,MAAA,EAAQ,QAAA,CAAS,QAAA;WAEjB,OAAA;WAEA,QAAA;WAEA,IAAA;EAPC;;;;EAAA,SAYD,UAAA,IACP,OAAA,EAAS,iBAAA,CAAkB,iBAAA,KACxB,iBAAA,CAAkB,iBAAA,EAAlB;EAAA,SAEI,YAAA,GAAe,MAAA,kBAEO;EAAA,SAAtB,SAAA,IAAa,OAAA,EAAS,aAAA,KAAkB,MAAA;AAAA;;cAgGtC,IAAA,GACX,GAAA,EAAK,UAAA,KACJ,MAAA,CAAO,MAAA,CAAO,oBAAA,SAA6B,UAAA,CAAW,UAAA;;cAW5C,KAAA,GAAS,GAAA,EAAK,UAAA,KAAa,KAAA,CAAM,KAAA,CAAM,aAAA,SAAsB,UAAA,CAAW,UAAA"}
@@ -0,0 +1,68 @@
1
+ import { t as __exportAll } from "./chunk-CfYAbeIz.mjs";
2
+ import { accumulatorToTurn, applyChunk, decodeChunk, emptyAccumulator, itemsToMessages, responseFormatWire, toolChoiceWire, toolsWire } from "./codec.mjs";
3
+ import { httpStatusError, transportFailure } from "./http.mjs";
4
+ import { Effect, Layer, Option, Result, Schema, Stream } from "effect";
5
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http";
6
+ import * as AiError from "@effect-uai/core/AiError";
7
+ import { LanguageModel, turnFromStream } from "@effect-uai/core/LanguageModel";
8
+ import * as SSE from "@effect-uai/core/SSE";
9
+ import { descriptorsOf } from "@effect-uai/core/Tool";
10
+ import { TurnEvent } from "@effect-uai/core/Turn";
11
+ //#region src/ChatCompletions.ts
12
+ var ChatCompletions_exports = /* @__PURE__ */ __exportAll({
13
+ layer: () => layer,
14
+ make: () => make
15
+ });
16
+ const buildRequestBody = (cfg, request) => {
17
+ const tools = toolsWire(descriptorsOf(request.tools));
18
+ return {
19
+ model: request.model,
20
+ stream: true,
21
+ messages: itemsToMessages(request.history),
22
+ ...request.temperature !== void 0 && { temperature: request.temperature },
23
+ ...request.topP !== void 0 && { top_p: request.topP },
24
+ ...request.maxOutputTokens !== void 0 && { max_tokens: request.maxOutputTokens },
25
+ ...Option.isSome(tools) && { tools: tools.value },
26
+ ...request.toolChoice !== void 0 && { tool_choice: toolChoiceWire(request.toolChoice) },
27
+ ...request.structured !== void 0 && { response_format: responseFormatWire(request.structured) },
28
+ ...cfg.extraBody?.(request) ?? {}
29
+ };
30
+ };
31
+ const parseJsonUnknown = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
32
+ /**
33
+ * Decode one SSE event's `data` payload into a `WireChunk`. Returns `None` for
34
+ * the `[DONE]` sentinel and for any payload that isn't a chunk we model, so a
35
+ * stray keep-alive never aborts the turn.
36
+ */
37
+ const decodeEvent = (data) => data.trim() === "[DONE]" ? Effect.succeedNone : parseJsonUnknown(data).pipe(Effect.flatMap(decodeChunk), Effect.option);
38
+ const buildStream = (cfg) => {
39
+ const url = `${cfg.baseUrl}${cfg.path ?? "/chat/completions"}`;
40
+ const auth = cfg.authHeader ?? HttpClientRequest.bearerToken(cfg.apiKey);
41
+ return (request) => Stream.unwrap(Effect.gen(function* () {
42
+ const client = yield* HttpClient.HttpClient;
43
+ const httpRequest = HttpClientRequest.post(url).pipe(auth, HttpClientRequest.setHeaders(cfg.extraHeaders ?? {}), HttpClientRequest.bodyJsonUnsafe(buildRequestBody(cfg, request)), HttpClientRequest.accept("text/event-stream"));
44
+ const response = yield* client.execute(httpRequest).pipe(Effect.mapError((cause) => transportFailure(cfg.provider, cause)));
45
+ if (response.status >= 400) {
46
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""));
47
+ return Stream.fail(httpStatusError(cfg.provider, response.status, text));
48
+ }
49
+ return response.stream.pipe(Stream.mapError((cause) => new AiError.Unavailable({
50
+ provider: cfg.provider,
51
+ raw: cause
52
+ })), SSE.fromBytes, Stream.mapEffect((ev) => decodeEvent(ev.data)), Stream.filterMap((chunk) => Option.isSome(chunk) ? Result.succeed(chunk.value) : Result.failVoid), Stream.mapAccum(() => emptyAccumulator, applyChunk, { onHalt: (acc) => Option.isSome(acc.finishReason) ? [TurnEvent.TurnComplete({ turn: accumulatorToTurn(acc) })] : [] }));
53
+ }));
54
+ };
55
+ /** Build a generic chat-completions `LanguageModelService`. */
56
+ const make = (cfg) => Effect.map(HttpClient.HttpClient, (client) => {
57
+ const streamTurn = (request) => buildStream(cfg)(request).pipe(Stream.provideService(HttpClient.HttpClient, client));
58
+ return {
59
+ streamTurn,
60
+ turn: turnFromStream(streamTurn)
61
+ };
62
+ });
63
+ /** Layer registering the generic `LanguageModel` tag for a compatible gateway. */
64
+ const layer = (cfg) => Layer.effect(LanguageModel, make(cfg));
65
+ //#endregion
66
+ export { layer, make, ChatCompletions_exports as t };
67
+
68
+ //# sourceMappingURL=ChatCompletions.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ChatCompletions.mjs","names":[],"sources":["../src/ChatCompletions.ts"],"sourcesContent":["import { Effect, Layer, Option, Redacted, Result, Schema, Stream } from \"effect\"\nimport { HttpClient, HttpClientRequest } from \"effect/unstable/http\"\nimport * as AiError from \"@effect-uai/core/AiError\"\nimport {\n type CommonRequest,\n LanguageModel,\n type LanguageModelService,\n turnFromStream,\n} from \"@effect-uai/core/LanguageModel\"\nimport * as SSE from \"@effect-uai/core/SSE\"\nimport { descriptorsOf } from \"@effect-uai/core/Tool\"\nimport { TurnEvent } from \"@effect-uai/core/Turn\"\nimport {\n type Accumulator,\n type WireChunk,\n accumulatorToTurn,\n applyChunk,\n decodeChunk,\n emptyAccumulator,\n itemsToMessages,\n responseFormatWire,\n toolChoiceWire,\n toolsWire,\n} from \"./codec.js\"\nimport { httpStatusError, transportFailure } from \"./http.js\"\n\n// ---------------------------------------------------------------------------\n// Config\n// ---------------------------------------------------------------------------\n\nexport type ChatConfig = {\n readonly apiKey: Redacted.Redacted\n /** Provider base URL, e.g. `https://openrouter.ai/api/v1`. No trailing slash. */\n readonly baseUrl: string\n /** Name used to tag `AiError`s from this provider. */\n readonly provider: string\n /** Request path appended to `baseUrl`. Defaults to `/chat/completions`. */\n readonly path?: string\n /**\n * Override the auth header. Defaults to `Authorization: Bearer <apiKey>`.\n * Supply this for gateways that authenticate differently (e.g. Azure `api-key`).\n */\n readonly authHeader?: (\n request: HttpClientRequest.HttpClientRequest,\n ) => HttpClientRequest.HttpClientRequest\n /** Extra headers merged into every request (e.g. OpenRouter `HTTP-Referer`). */\n readonly extraHeaders?: Record<string, string>\n /** Provider-specific request-body fields merged into the wire body. */\n readonly extraBody?: (request: CommonRequest) => Record<string, unknown>\n}\n\n// ---------------------------------------------------------------------------\n// Request body\n// ---------------------------------------------------------------------------\n\nconst buildRequestBody = (cfg: ChatConfig, request: CommonRequest): Record<string, unknown> => {\n const tools = toolsWire(descriptorsOf(request.tools))\n return {\n model: request.model,\n stream: true,\n messages: itemsToMessages(request.history),\n ...(request.temperature !== undefined && { temperature: request.temperature }),\n ...(request.topP !== undefined && { top_p: request.topP }),\n ...(request.maxOutputTokens !== undefined && { max_tokens: request.maxOutputTokens }),\n ...(Option.isSome(tools) && { tools: tools.value }),\n ...(request.toolChoice !== undefined && { tool_choice: toolChoiceWire(request.toolChoice) }),\n ...(request.structured !== undefined && {\n response_format: responseFormatWire(request.structured),\n }),\n ...(cfg.extraBody?.(request) ?? {}),\n }\n}\n\n// ---------------------------------------------------------------------------\n// SSE → WireChunk\n// ---------------------------------------------------------------------------\n\nconst parseJsonUnknown = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))\n\n/**\n * Decode one SSE event's `data` payload into a `WireChunk`. Returns `None` for\n * the `[DONE]` sentinel and for any payload that isn't a chunk we model, so a\n * stray keep-alive never aborts the turn.\n */\nconst decodeEvent = (data: string): Effect.Effect<Option.Option<WireChunk>> =>\n data.trim() === \"[DONE]\"\n ? Effect.succeedNone\n : parseJsonUnknown(data).pipe(Effect.flatMap(decodeChunk), Effect.option)\n\n// ---------------------------------------------------------------------------\n// Stream\n// ---------------------------------------------------------------------------\n\nconst buildStream = (cfg: ChatConfig) => {\n const url = `${cfg.baseUrl}${cfg.path ?? \"/chat/completions\"}`\n const auth = cfg.authHeader ?? HttpClientRequest.bearerToken(cfg.apiKey)\n return (\n request: CommonRequest,\n ): Stream.Stream<TurnEvent, AiError.AiError, HttpClient.HttpClient> =>\n Stream.unwrap(\n Effect.gen(function* () {\n const client = yield* HttpClient.HttpClient\n const httpRequest = HttpClientRequest.post(url).pipe(\n auth,\n HttpClientRequest.setHeaders(cfg.extraHeaders ?? {}),\n HttpClientRequest.bodyJsonUnsafe(buildRequestBody(cfg, request)),\n HttpClientRequest.accept(\"text/event-stream\"),\n )\n const response = yield* client\n .execute(httpRequest)\n .pipe(Effect.mapError((cause) => transportFailure(cfg.provider, cause)))\n if (response.status >= 400) {\n const text = yield* response.text.pipe(Effect.orElseSucceed(() => \"\"))\n return Stream.fail(httpStatusError(cfg.provider, response.status, text))\n }\n\n return response.stream.pipe(\n Stream.mapError(\n (cause): AiError.AiError =>\n new AiError.Unavailable({ provider: cfg.provider, raw: cause }),\n ),\n SSE.fromBytes,\n Stream.mapEffect((ev) => decodeEvent(ev.data)),\n Stream.filterMap((chunk) =>\n Option.isSome(chunk) ? Result.succeed(chunk.value) : Result.failVoid,\n ),\n Stream.mapAccum((): Accumulator => emptyAccumulator, applyChunk, {\n // `onHalt` also fires on upstream failure and truncated streams, so\n // only emit `TurnComplete` once a `finish_reason` was observed.\n onHalt: (acc) =>\n Option.isSome(acc.finishReason)\n ? [TurnEvent.TurnComplete({ turn: accumulatorToTurn(acc) })]\n : [],\n }),\n )\n }),\n )\n}\n\n// ---------------------------------------------------------------------------\n// Constructors\n// ---------------------------------------------------------------------------\n\n/** Build a generic chat-completions `LanguageModelService`. */\nexport const make = (\n cfg: ChatConfig,\n): Effect.Effect<LanguageModelService, never, HttpClient.HttpClient> =>\n Effect.map(HttpClient.HttpClient, (client) => {\n const streamTurn: LanguageModelService[\"streamTurn\"] = (request) =>\n buildStream(cfg)(request).pipe(Stream.provideService(HttpClient.HttpClient, client))\n return {\n streamTurn,\n turn: turnFromStream(streamTurn),\n }\n })\n\n/** Layer registering the generic `LanguageModel` tag for a compatible gateway. */\nexport const layer = (cfg: ChatConfig): Layer.Layer<LanguageModel, never, HttpClient.HttpClient> =>\n Layer.effect(LanguageModel, make(cfg))\n"],"mappings":";;;;;;;;;;;;;;;AAuDA,MAAM,oBAAoB,KAAiB,YAAoD;CAC7F,MAAM,QAAQ,UAAU,cAAc,QAAQ,MAAM,CAAC;AACrD,QAAO;EACL,OAAO,QAAQ;EACf,QAAQ;EACR,UAAU,gBAAgB,QAAQ,QAAQ;EAC1C,GAAI,QAAQ,gBAAgB,KAAA,KAAa,EAAE,aAAa,QAAQ,aAAa;EAC7E,GAAI,QAAQ,SAAS,KAAA,KAAa,EAAE,OAAO,QAAQ,MAAM;EACzD,GAAI,QAAQ,oBAAoB,KAAA,KAAa,EAAE,YAAY,QAAQ,iBAAiB;EACpF,GAAI,OAAO,OAAO,MAAM,IAAI,EAAE,OAAO,MAAM,OAAO;EAClD,GAAI,QAAQ,eAAe,KAAA,KAAa,EAAE,aAAa,eAAe,QAAQ,WAAW,EAAE;EAC3F,GAAI,QAAQ,eAAe,KAAA,KAAa,EACtC,iBAAiB,mBAAmB,QAAQ,WAAW,EACxD;EACD,GAAI,IAAI,YAAY,QAAQ,IAAI,EAAE;EACnC;;AAOH,MAAM,mBAAmB,OAAO,oBAAoB,OAAO,eAAe,OAAO,QAAQ,CAAC;;;;;;AAO1F,MAAM,eAAe,SACnB,KAAK,MAAM,KAAK,WACZ,OAAO,cACP,iBAAiB,KAAK,CAAC,KAAK,OAAO,QAAQ,YAAY,EAAE,OAAO,OAAO;AAM7E,MAAM,eAAe,QAAoB;CACvC,MAAM,MAAM,GAAG,IAAI,UAAU,IAAI,QAAQ;CACzC,MAAM,OAAO,IAAI,cAAc,kBAAkB,YAAY,IAAI,OAAO;AACxE,SACE,YAEA,OAAO,OACL,OAAO,IAAI,aAAa;EACtB,MAAM,SAAS,OAAO,WAAW;EACjC,MAAM,cAAc,kBAAkB,KAAK,IAAI,CAAC,KAC9C,MACA,kBAAkB,WAAW,IAAI,gBAAgB,EAAE,CAAC,EACpD,kBAAkB,eAAe,iBAAiB,KAAK,QAAQ,CAAC,EAChE,kBAAkB,OAAO,oBAAoB,CAC9C;EACD,MAAM,WAAW,OAAO,OACrB,QAAQ,YAAY,CACpB,KAAK,OAAO,UAAU,UAAU,iBAAiB,IAAI,UAAU,MAAM,CAAC,CAAC;AAC1E,MAAI,SAAS,UAAU,KAAK;GAC1B,MAAM,OAAO,OAAO,SAAS,KAAK,KAAK,OAAO,oBAAoB,GAAG,CAAC;AACtE,UAAO,OAAO,KAAK,gBAAgB,IAAI,UAAU,SAAS,QAAQ,KAAK,CAAC;;AAG1E,SAAO,SAAS,OAAO,KACrB,OAAO,UACJ,UACC,IAAI,QAAQ,YAAY;GAAE,UAAU,IAAI;GAAU,KAAK;GAAO,CAAC,CAClE,EACD,IAAI,WACJ,OAAO,WAAW,OAAO,YAAY,GAAG,KAAK,CAAC,EAC9C,OAAO,WAAW,UAChB,OAAO,OAAO,MAAM,GAAG,OAAO,QAAQ,MAAM,MAAM,GAAG,OAAO,SAC7D,EACD,OAAO,eAA4B,kBAAkB,YAAY,EAG/D,SAAS,QACP,OAAO,OAAO,IAAI,aAAa,GAC3B,CAAC,UAAU,aAAa,EAAE,MAAM,kBAAkB,IAAI,EAAE,CAAC,CAAC,GAC1D,EAAE,EACT,CAAC,CACH;GACD,CACH;;;AAQL,MAAa,QACX,QAEA,OAAO,IAAI,WAAW,aAAa,WAAW;CAC5C,MAAM,cAAkD,YACtD,YAAY,IAAI,CAAC,QAAQ,CAAC,KAAK,OAAO,eAAe,WAAW,YAAY,OAAO,CAAC;AACtF,QAAO;EACL;EACA,MAAM,eAAe,WAAW;EACjC;EACD;;AAGJ,MAAa,SAAS,QACpB,MAAM,OAAO,eAAe,KAAK,IAAI,CAAC"}
@@ -0,0 +1,13 @@
1
+ //#region \0rolldown/runtime.js
2
+ var __defProp = Object.defineProperty;
3
+ var __exportAll = (all, no_symbols) => {
4
+ let target = {};
5
+ for (var name in all) __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true
8
+ });
9
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
10
+ return target;
11
+ };
12
+ //#endregion
13
+ export { __exportAll as t };
@@ -0,0 +1,99 @@
1
+ import { Option, Schema } from "effect";
2
+ import { ToolDescriptor } from "@effect-uai/core/Tool";
3
+ import { Turn, TurnEvent } from "@effect-uai/core/Turn";
4
+ import * as _$effect_SchemaAST0 from "effect/SchemaAST";
5
+ import * as _$effect_Effect0 from "effect/Effect";
6
+ import { HistoryItem, Usage } from "@effect-uai/core/Items";
7
+ import { StructuredFormat } from "@effect-uai/core/StructuredFormat";
8
+
9
+ //#region src/codec.d.ts
10
+ declare namespace codec_d_exports {
11
+ export { Accumulator, WireChunk, accumulatorToTurn, applyChunk, decodeChunk, emptyAccumulator, itemsToMessages, responseFormatWire, toolChoiceWire, toolsWire };
12
+ }
13
+ type WireMessage = {
14
+ role: string;
15
+ content: string | ReadonlyArray<Record<string, unknown>>;
16
+ tool_calls?: ReadonlyArray<Record<string, unknown>>;
17
+ tool_call_id?: string;
18
+ };
19
+ /** Convert our `HistoryItem[]` history into chat `messages`. */
20
+ declare const itemsToMessages: (items: ReadonlyArray<HistoryItem>) => ReadonlyArray<WireMessage>;
21
+ declare const toolsWire: (descriptors: ReadonlyArray<ToolDescriptor>) => Option.Option<ReadonlyArray<Record<string, unknown>>>;
22
+ type ToolChoice = "auto" | "required" | "none" | {
23
+ readonly type: "function";
24
+ readonly name: string;
25
+ };
26
+ declare const toolChoiceWire: (choice: ToolChoice) => string | Record<string, unknown>;
27
+ declare const responseFormatWire: (structured: StructuredFormat<unknown>) => Record<string, unknown>;
28
+ declare const WireChunk: Schema.Struct<{
29
+ readonly choices: Schema.optional<Schema.$Array<Schema.Struct<{
30
+ readonly index: Schema.optional<Schema.Number>;
31
+ readonly delta: Schema.optional<Schema.Struct<{
32
+ readonly role: Schema.optional<Schema.NullOr<Schema.String>>;
33
+ readonly content: Schema.optional<Schema.NullOr<Schema.String>>;
34
+ readonly tool_calls: Schema.optional<Schema.NullOr<Schema.$Array<Schema.Struct<{
35
+ readonly index: Schema.optional<Schema.Number>;
36
+ readonly id: Schema.optional<Schema.NullOr<Schema.String>>;
37
+ readonly function: Schema.optional<Schema.Struct<{
38
+ readonly name: Schema.optional<Schema.NullOr<Schema.String>>;
39
+ readonly arguments: Schema.optional<Schema.NullOr<Schema.String>>;
40
+ }>>;
41
+ }>>>>;
42
+ }>>;
43
+ readonly finish_reason: Schema.optional<Schema.NullOr<Schema.String>>;
44
+ }>>>;
45
+ readonly usage: Schema.optional<Schema.NullOr<Schema.Struct<{
46
+ readonly prompt_tokens: Schema.optional<Schema.Number>;
47
+ readonly completion_tokens: Schema.optional<Schema.Number>;
48
+ readonly total_tokens: Schema.optional<Schema.Number>;
49
+ }>>>;
50
+ }>;
51
+ type WireChunk = typeof WireChunk.Type;
52
+ declare const decodeChunk: (input: unknown, options?: _$effect_SchemaAST0.ParseOptions) => _$effect_Effect0.Effect<{
53
+ readonly choices?: readonly {
54
+ readonly index?: number | undefined;
55
+ readonly delta?: {
56
+ readonly role?: string | null | undefined;
57
+ readonly content?: string | null | undefined;
58
+ readonly tool_calls?: readonly {
59
+ readonly function?: {
60
+ readonly name?: string | null | undefined;
61
+ readonly arguments?: string | null | undefined;
62
+ } | undefined;
63
+ readonly id?: string | null | undefined;
64
+ readonly index?: number | undefined;
65
+ }[] | null | undefined;
66
+ } | undefined;
67
+ readonly finish_reason?: string | null | undefined;
68
+ }[] | undefined;
69
+ readonly usage?: {
70
+ readonly prompt_tokens?: number | undefined;
71
+ readonly completion_tokens?: number | undefined;
72
+ readonly total_tokens?: number | undefined;
73
+ } | null | undefined;
74
+ }, Schema.SchemaError, never>;
75
+ type ToolAcc = {
76
+ readonly call_id: string;
77
+ readonly name: string;
78
+ readonly arguments: string;
79
+ };
80
+ type Accumulator = {
81
+ readonly text: string; /** Tool calls keyed by their wire `index` (defaults to position when absent). */
82
+ readonly tools: ReadonlyMap<number, ToolAcc>;
83
+ readonly order: ReadonlyArray<number>;
84
+ readonly usage: Usage;
85
+ readonly finishReason: Option.Option<string>;
86
+ };
87
+ declare const emptyAccumulator: Accumulator;
88
+ /** State threaded through the fold plus the events the step produced. */
89
+ type Step = readonly [Accumulator, ReadonlyArray<TurnEvent>];
90
+ /**
91
+ * Fold one decoded chunk into the accumulator, returning the next state and the
92
+ * `TurnEvent`s the chunk produced.
93
+ */
94
+ declare const applyChunk: (acc: Accumulator, chunk: WireChunk) => Step;
95
+ /** Assemble the terminal `Turn` from the accumulated state. */
96
+ declare const accumulatorToTurn: (acc: Accumulator) => Turn;
97
+ //#endregion
98
+ export { Accumulator, WireChunk, accumulatorToTurn, applyChunk, decodeChunk, emptyAccumulator, itemsToMessages, responseFormatWire, codec_d_exports as t, toolChoiceWire, toolsWire };
99
+ //# sourceMappingURL=codec.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codec.d.mts","names":[],"sources":["../src/codec.ts"],"mappings":";;;;;;;;;;;;KAwBK,WAAA;EACH,IAAA;EACA,OAAA,WAAkB,aAAA,CAAc,MAAA;EAChC,UAAA,GAAa,aAAA,CAAc,MAAA;EAC3B,YAAA;AAAA;;cAuEW,eAAA,GAAmB,KAAA,EAAO,aAAA,CAAc,WAAA,MAAe,aAAA,CAAc,WAAA;AAAA,cAOrE,SAAA,GACX,WAAA,EAAa,aAAA,CAAc,cAAA,MAC1B,MAAA,CAAO,MAAA,CAAO,aAAA,CAAc,MAAA;AAAA,KAe1B,UAAA;EAAA,SAIU,IAAA;EAAA,SAA2B,IAAA;AAAA;AAAA,cAE7B,cAAA,GAAkB,MAAA,EAAQ,UAAA,cAAsB,MAAA;AAAA,cAQhD,kBAAA,GACX,UAAA,EAAY,gBAAA,cACX,MAAA;AAAA,cA0CU,SAAA,EAAS,MAAA,CAAA,MAAA;EAAA;;;;;;;;;;;;;;;;;;;;;;KAIV,SAAA,UAAmB,SAAA,CAAU,IAAA;AAAA,cAE5B,WAAA,GAAW,KAAA,WAAA,OAAA,GAAwC,mBAAA,CAAxC,YAAA,sBAAA,MAAA;EAAA;;;;;;;;;;;;;;;;;;;;;;KAMnB,OAAA;EAAA,SACM,OAAA;EAAA,SACA,IAAA;EAAA,SACA,SAAA;AAAA;AAAA,KAGC,WAAA;EAAA,SACD,IAAA,UA5F0B;EAAA,SA8F1B,KAAA,EAAO,WAAA,SAAoB,OAAA;EAAA,SAC3B,KAAA,EAAO,aAAA;EAAA,SACP,KAAA,EAAO,KAAA;EAAA,SACP,YAAA,EAAc,MAAA,CAAO,MAAA;AAAA;AAAA,cAGnB,gBAAA,EAAkB,WAAA;;KAS1B,IAAA,aAAiB,WAAA,EAAa,aAAA,CAAc,SAAA;;;;;cAqFpC,UAAA,GAAc,GAAA,EAAK,WAAA,EAAa,KAAA,EAAO,SAAA,KAAY,IAAA;;cAYnD,iBAAA,GAAqB,GAAA,EAAK,WAAA,KAAc,IAAA"}
package/dist/codec.mjs ADDED
@@ -0,0 +1,242 @@
1
+ import { t as __exportAll } from "./chunk-CfYAbeIz.mjs";
2
+ import { Array, Encoding, Match, Option, Schema } from "effect";
3
+ import { TurnEvent } from "@effect-uai/core/Turn";
4
+ //#region src/codec.ts
5
+ var codec_exports = /* @__PURE__ */ __exportAll({
6
+ WireChunk: () => WireChunk,
7
+ accumulatorToTurn: () => accumulatorToTurn,
8
+ applyChunk: () => applyChunk,
9
+ decodeChunk: () => decodeChunk,
10
+ emptyAccumulator: () => emptyAccumulator,
11
+ itemsToMessages: () => itemsToMessages,
12
+ responseFormatWire: () => responseFormatWire,
13
+ toolChoiceWire: () => toolChoiceWire,
14
+ toolsWire: () => toolsWire
15
+ });
16
+ /** A `data:` URI for inline image bytes; URLs pass through untouched. */
17
+ const imageSourceToUrl = Match.type().pipe(Match.tag("url", (s) => s.url), Match.tag("base64", (s) => `data:${s.mimeType};base64,${s.base64}`), Match.tag("bytes", (s) => `data:${s.mimeType};base64,${Encoding.encodeBase64(s.bytes)}`), Match.exhaustive);
18
+ const blockToPart = Match.type().pipe(Match.discriminatorsExhaustive("type")({
19
+ input_text: (b) => ({
20
+ type: "text",
21
+ text: b.text
22
+ }),
23
+ output_text: (b) => ({
24
+ type: "text",
25
+ text: b.text
26
+ }),
27
+ refusal: (b) => ({
28
+ type: "text",
29
+ text: b.text
30
+ }),
31
+ input_image: (b) => ({
32
+ type: "image_url",
33
+ image_url: { url: imageSourceToUrl(b.source) }
34
+ })
35
+ }));
36
+ const isTextBlock = (b) => b.type === "input_text" || b.type === "output_text" || b.type === "refusal";
37
+ const textOfBlock = (b) => b.type === "input_image" ? "" : b.text;
38
+ /**
39
+ * Collapse a message's content to a plain string when it is text-only (the
40
+ * common case), otherwise emit the multimodal parts array.
41
+ */
42
+ const encodeContent = (content) => Array.every(content, isTextBlock) ? content.map(textOfBlock).join("") : content.map(blockToPart);
43
+ const toolCallWire = (call_id, name, args) => ({
44
+ id: call_id,
45
+ type: "function",
46
+ function: {
47
+ name,
48
+ arguments: args
49
+ }
50
+ });
51
+ const appendToolCall = (acc, call_id, name, args) => {
52
+ const last = acc[acc.length - 1];
53
+ const wire = toolCallWire(call_id, name, args);
54
+ if (last !== void 0 && last.role === "assistant" && last.tool_call_id === void 0) return [...acc.slice(0, -1), {
55
+ ...last,
56
+ tool_calls: [...last.tool_calls ?? [], wire]
57
+ }];
58
+ return [...acc, {
59
+ role: "assistant",
60
+ content: "",
61
+ tool_calls: [wire]
62
+ }];
63
+ };
64
+ const foldItem = (acc, item) => Match.value(item).pipe(Match.discriminatorsExhaustive("type")({
65
+ message: (m) => [...acc, {
66
+ role: m.role,
67
+ content: encodeContent(m.content)
68
+ }],
69
+ function_call: (f) => appendToolCall(acc, f.call_id, f.name, f.arguments),
70
+ function_call_output: (o) => [...acc, {
71
+ role: "tool",
72
+ tool_call_id: o.call_id,
73
+ content: o.output
74
+ }],
75
+ reasoning: () => acc
76
+ }));
77
+ /** Convert our `HistoryItem[]` history into chat `messages`. */
78
+ const itemsToMessages = (items) => Array.reduce(items, [], foldItem);
79
+ const toolsWire = (descriptors) => descriptors.length > 0 ? Option.some(descriptors.map((t) => ({
80
+ type: "function",
81
+ function: {
82
+ name: t.name,
83
+ description: t.description,
84
+ parameters: t.inputSchema,
85
+ ...t.strict !== void 0 && { strict: t.strict }
86
+ }
87
+ }))) : Option.none();
88
+ const toolChoiceWire = (choice) => Match.value(choice).pipe(Match.when("auto", () => "auto"), Match.when("required", () => "required"), Match.when("none", () => "none"), Match.orElse((c) => ({
89
+ type: "function",
90
+ function: { name: c.name }
91
+ })));
92
+ const responseFormatWire = (structured) => ({
93
+ type: "json_schema",
94
+ json_schema: {
95
+ name: structured.name,
96
+ schema: structured.schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }),
97
+ ...structured.strict !== void 0 && { strict: structured.strict }
98
+ }
99
+ });
100
+ const WireFunction = Schema.Struct({
101
+ name: Schema.optional(Schema.NullOr(Schema.String)),
102
+ arguments: Schema.optional(Schema.NullOr(Schema.String))
103
+ });
104
+ const WireToolCall = Schema.Struct({
105
+ index: Schema.optional(Schema.Number),
106
+ id: Schema.optional(Schema.NullOr(Schema.String)),
107
+ function: Schema.optional(WireFunction)
108
+ });
109
+ const WireDelta = Schema.Struct({
110
+ role: Schema.optional(Schema.NullOr(Schema.String)),
111
+ content: Schema.optional(Schema.NullOr(Schema.String)),
112
+ tool_calls: Schema.optional(Schema.NullOr(Schema.Array(WireToolCall)))
113
+ });
114
+ const WireChoice = Schema.Struct({
115
+ index: Schema.optional(Schema.Number),
116
+ delta: Schema.optional(WireDelta),
117
+ finish_reason: Schema.optional(Schema.NullOr(Schema.String))
118
+ });
119
+ const WireUsage = Schema.Struct({
120
+ prompt_tokens: Schema.optional(Schema.Number),
121
+ completion_tokens: Schema.optional(Schema.Number),
122
+ total_tokens: Schema.optional(Schema.Number)
123
+ });
124
+ const WireChunk = Schema.Struct({
125
+ choices: Schema.optional(Schema.Array(WireChoice)),
126
+ usage: Schema.optional(Schema.NullOr(WireUsage))
127
+ });
128
+ const decodeChunk = Schema.decodeUnknownEffect(WireChunk);
129
+ const emptyAccumulator = {
130
+ text: "",
131
+ tools: /* @__PURE__ */ new Map(),
132
+ order: [],
133
+ usage: {},
134
+ finishReason: Option.none()
135
+ };
136
+ const usageFrom = (wire) => ({
137
+ ...wire.prompt_tokens !== void 0 && { input_tokens: wire.prompt_tokens },
138
+ ...wire.completion_tokens !== void 0 && { output_tokens: wire.completion_tokens },
139
+ ...wire.total_tokens !== void 0 && { total_tokens: wire.total_tokens }
140
+ });
141
+ const reasonToStop = Match.type().pipe(Match.when("stop", () => "stop"), Match.when("length", () => "max_tokens"), Match.when("tool_calls", () => "tool_calls"), Match.when("content_filter", () => "content_filter"), Match.orElse(() => "stop"));
142
+ const stopReasonOf = (acc) => Option.match(acc.finishReason, {
143
+ onNone: () => acc.tools.size > 0 ? "tool_calls" : "stop",
144
+ onSome: reasonToStop
145
+ });
146
+ const withTool = (acc, index, tool, isNew) => ({
147
+ ...acc,
148
+ tools: new Map(acc.tools).set(index, tool),
149
+ order: isNew ? [...acc.order, index] : acc.order
150
+ });
151
+ const applyContent = (acc, delta) => {
152
+ const content = delta?.content;
153
+ return content !== void 0 && content !== null && content.length > 0 ? [{
154
+ ...acc,
155
+ text: acc.text + content
156
+ }, [TurnEvent.TextDelta({ text: content })]] : [acc, []];
157
+ };
158
+ const applyToolCall = (acc, tc, position) => {
159
+ const index = tc.index ?? position;
160
+ const argsDelta = tc.function?.arguments ?? "";
161
+ const existing = acc.tools.get(index);
162
+ if (existing === void 0) {
163
+ const call_id = tc.id ?? String(index).padStart(9, "0");
164
+ const name = tc.function?.name ?? "";
165
+ return [withTool(acc, index, {
166
+ call_id,
167
+ name,
168
+ arguments: argsDelta
169
+ }, true), [TurnEvent.ToolCallStart({
170
+ call_id,
171
+ name
172
+ }), ...argsDelta.length > 0 ? [TurnEvent.ToolCallArgsDelta({
173
+ call_id,
174
+ delta: argsDelta
175
+ })] : []]];
176
+ }
177
+ return argsDelta.length > 0 ? [withTool(acc, index, {
178
+ ...existing,
179
+ arguments: existing.arguments + argsDelta
180
+ }, false), [TurnEvent.ToolCallArgsDelta({
181
+ call_id: existing.call_id,
182
+ delta: argsDelta
183
+ })]] : [acc, []];
184
+ };
185
+ const chain = (step, next) => {
186
+ const [acc, events] = step;
187
+ const [acc2, more] = next(acc);
188
+ return [acc2, [...events, ...more]];
189
+ };
190
+ const applyChoice = (step, choice) => {
191
+ const withContent = chain(step, (acc) => applyContent(acc, choice.delta));
192
+ const withTools = Array.reduce(choice.delta?.tool_calls ?? [], withContent, (s, tc, i) => chain(s, (acc) => applyToolCall(acc, tc, i)));
193
+ const reason = choice.finish_reason;
194
+ return reason !== void 0 && reason !== null ? [{
195
+ ...withTools[0],
196
+ finishReason: Option.some(reason)
197
+ }, withTools[1]] : withTools;
198
+ };
199
+ /**
200
+ * Fold one decoded chunk into the accumulator, returning the next state and the
201
+ * `TurnEvent`s the chunk produced.
202
+ */
203
+ const applyChunk = (acc, chunk) => {
204
+ const afterChoices = Array.reduce(chunk.choices ?? [], [acc, []], applyChoice);
205
+ const usage = chunk.usage;
206
+ return usage !== void 0 && usage !== null ? chain(afterChoices, (a) => {
207
+ const u = usageFrom(usage);
208
+ return [{
209
+ ...a,
210
+ usage: u
211
+ }, [TurnEvent.UsageUpdate({ usage: u })]];
212
+ }) : afterChoices;
213
+ };
214
+ /** Assemble the terminal `Turn` from the accumulated state. */
215
+ const accumulatorToTurn = (acc) => {
216
+ const message = acc.text.length > 0 ? [{
217
+ type: "message",
218
+ role: "assistant",
219
+ content: [{
220
+ type: "output_text",
221
+ text: acc.text
222
+ }]
223
+ }] : [];
224
+ const toolCalls = acc.order.flatMap((index) => {
225
+ const t = acc.tools.get(index);
226
+ return t === void 0 ? [] : [{
227
+ type: "function_call",
228
+ call_id: t.call_id,
229
+ name: t.name,
230
+ arguments: t.arguments
231
+ }];
232
+ });
233
+ return {
234
+ items: [...message, ...toolCalls],
235
+ usage: acc.usage,
236
+ stop_reason: stopReasonOf(acc)
237
+ };
238
+ };
239
+ //#endregion
240
+ export { WireChunk, accumulatorToTurn, applyChunk, decodeChunk, emptyAccumulator, itemsToMessages, responseFormatWire, codec_exports as t, toolChoiceWire, toolsWire };
241
+
242
+ //# sourceMappingURL=codec.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codec.mjs","names":["Arr"],"sources":["../src/codec.ts"],"sourcesContent":["import { Array as Arr, Encoding, Match, Option, Schema } from \"effect\"\nimport type {\n ContentBlock,\n HistoryItem,\n InputImage,\n StopReason,\n Usage,\n} from \"@effect-uai/core/Items\"\nimport type { StructuredFormat } from \"@effect-uai/core/StructuredFormat\"\nimport type { ToolDescriptor } from \"@effect-uai/core/Tool\"\nimport type { Turn } from \"@effect-uai/core/Turn\"\nimport { TurnEvent } from \"@effect-uai/core/Turn\"\n\n// ---------------------------------------------------------------------------\n// History → chat `messages`\n//\n// The OpenAI Chat Completions dialect: a flat list of `{ role, content, ... }`\n// objects where an assistant turn's tool calls live on the assistant message\n// (`tool_calls`) and each tool result is its own\n// `{ role: \"tool\", tool_call_id, content }` message. Our history is a flatter\n// item list (assistant message, then separate `function_call` items), so we\n// fold consecutive `function_call`s onto the preceding assistant message.\n// ---------------------------------------------------------------------------\n\ntype WireMessage = {\n role: string\n content: string | ReadonlyArray<Record<string, unknown>>\n tool_calls?: ReadonlyArray<Record<string, unknown>>\n tool_call_id?: string\n}\n\n/** A `data:` URI for inline image bytes; URLs pass through untouched. */\nconst imageSourceToUrl = Match.type<InputImage[\"source\"]>().pipe(\n Match.tag(\"url\", (s) => s.url),\n Match.tag(\"base64\", (s) => `data:${s.mimeType};base64,${s.base64}`),\n Match.tag(\"bytes\", (s) => `data:${s.mimeType};base64,${Encoding.encodeBase64(s.bytes)}`),\n Match.exhaustive,\n)\n\nconst blockToPart = Match.type<ContentBlock>().pipe(\n Match.discriminatorsExhaustive(\"type\")({\n input_text: (b) => ({ type: \"text\", text: b.text }),\n output_text: (b) => ({ type: \"text\", text: b.text }),\n refusal: (b) => ({ type: \"text\", text: b.text }),\n input_image: (b) => ({ type: \"image_url\", image_url: { url: imageSourceToUrl(b.source) } }),\n }),\n)\n\nconst isTextBlock = (b: ContentBlock): boolean =>\n b.type === \"input_text\" || b.type === \"output_text\" || b.type === \"refusal\"\n\nconst textOfBlock = (b: ContentBlock): string => (b.type === \"input_image\" ? \"\" : b.text)\n\n/**\n * Collapse a message's content to a plain string when it is text-only (the\n * common case), otherwise emit the multimodal parts array.\n */\nconst encodeContent = (\n content: ReadonlyArray<ContentBlock>,\n): string | ReadonlyArray<Record<string, unknown>> =>\n Arr.every(content, isTextBlock) ? content.map(textOfBlock).join(\"\") : content.map(blockToPart)\n\nconst toolCallWire = (call_id: string, name: string, args: string): Record<string, unknown> => ({\n id: call_id,\n type: \"function\",\n function: { name, arguments: args },\n})\n\nconst appendToolCall = (\n acc: ReadonlyArray<WireMessage>,\n call_id: string,\n name: string,\n args: string,\n): ReadonlyArray<WireMessage> => {\n const last = acc[acc.length - 1]\n const wire = toolCallWire(call_id, name, args)\n // Attach to the immediately-preceding assistant message (not a tool result),\n // matching the shape where tool calls ride the assistant turn.\n if (last !== undefined && last.role === \"assistant\" && last.tool_call_id === undefined) {\n return [...acc.slice(0, -1), { ...last, tool_calls: [...(last.tool_calls ?? []), wire] }]\n }\n return [...acc, { role: \"assistant\", content: \"\", tool_calls: [wire] }]\n}\n\nconst foldItem = (acc: ReadonlyArray<WireMessage>, item: HistoryItem): ReadonlyArray<WireMessage> =>\n Match.value(item).pipe(\n Match.discriminatorsExhaustive(\"type\")({\n message: (m) => [...acc, { role: m.role, content: encodeContent(m.content) }],\n function_call: (f) => appendToolCall(acc, f.call_id, f.name, f.arguments),\n function_call_output: (o) => [\n ...acc,\n { role: \"tool\", tool_call_id: o.call_id, content: o.output },\n ],\n // Reasoning items are not replayed to the chat-completions endpoint.\n reasoning: () => acc,\n }),\n )\n\n/** Convert our `HistoryItem[]` history into chat `messages`. */\nexport const itemsToMessages = (items: ReadonlyArray<HistoryItem>): ReadonlyArray<WireMessage> =>\n Arr.reduce(items, [] as ReadonlyArray<WireMessage>, foldItem)\n\n// ---------------------------------------------------------------------------\n// tools / tool_choice / response_format\n// ---------------------------------------------------------------------------\n\nexport const toolsWire = (\n descriptors: ReadonlyArray<ToolDescriptor>,\n): Option.Option<ReadonlyArray<Record<string, unknown>>> =>\n descriptors.length > 0\n ? Option.some(\n descriptors.map((t) => ({\n type: \"function\",\n function: {\n name: t.name,\n description: t.description,\n parameters: t.inputSchema,\n ...(t.strict !== undefined && { strict: t.strict }),\n },\n })),\n )\n : Option.none()\n\ntype ToolChoice =\n | \"auto\"\n | \"required\"\n | \"none\"\n | { readonly type: \"function\"; readonly name: string }\n\nexport const toolChoiceWire = (choice: ToolChoice): string | Record<string, unknown> =>\n Match.value(choice).pipe(\n Match.when(\"auto\", () => \"auto\" as const),\n Match.when(\"required\", () => \"required\" as const),\n Match.when(\"none\", () => \"none\" as const),\n Match.orElse((c) => ({ type: \"function\", function: { name: c.name } })),\n )\n\nexport const responseFormatWire = (\n structured: StructuredFormat<unknown>,\n): Record<string, unknown> => ({\n type: \"json_schema\",\n json_schema: {\n name: structured.name,\n schema: structured.schema[\"~standard\"].jsonSchema.input({ target: \"draft-2020-12\" }),\n ...(structured.strict !== undefined && { strict: structured.strict }),\n },\n})\n\n// ---------------------------------------------------------------------------\n// Streaming decode: chat.completion.chunk → TurnEvent\n// ---------------------------------------------------------------------------\n\nconst WireFunction = Schema.Struct({\n name: Schema.optional(Schema.NullOr(Schema.String)),\n arguments: Schema.optional(Schema.NullOr(Schema.String)),\n})\n\nconst WireToolCall = Schema.Struct({\n index: Schema.optional(Schema.Number),\n id: Schema.optional(Schema.NullOr(Schema.String)),\n function: Schema.optional(WireFunction),\n})\n\nconst WireDelta = Schema.Struct({\n role: Schema.optional(Schema.NullOr(Schema.String)),\n content: Schema.optional(Schema.NullOr(Schema.String)),\n tool_calls: Schema.optional(Schema.NullOr(Schema.Array(WireToolCall))),\n})\n\nconst WireChoice = Schema.Struct({\n index: Schema.optional(Schema.Number),\n delta: Schema.optional(WireDelta),\n finish_reason: Schema.optional(Schema.NullOr(Schema.String)),\n})\n\nconst WireUsage = Schema.Struct({\n prompt_tokens: Schema.optional(Schema.Number),\n completion_tokens: Schema.optional(Schema.Number),\n total_tokens: Schema.optional(Schema.Number),\n})\n\nexport const WireChunk = Schema.Struct({\n choices: Schema.optional(Schema.Array(WireChoice)),\n usage: Schema.optional(Schema.NullOr(WireUsage)),\n})\nexport type WireChunk = typeof WireChunk.Type\n\nexport const decodeChunk = Schema.decodeUnknownEffect(WireChunk)\n\n// ---------------------------------------------------------------------------\n// Accumulator\n// ---------------------------------------------------------------------------\n\ntype ToolAcc = {\n readonly call_id: string\n readonly name: string\n readonly arguments: string\n}\n\nexport type Accumulator = {\n readonly text: string\n /** Tool calls keyed by their wire `index` (defaults to position when absent). */\n readonly tools: ReadonlyMap<number, ToolAcc>\n readonly order: ReadonlyArray<number>\n readonly usage: Usage\n readonly finishReason: Option.Option<string>\n}\n\nexport const emptyAccumulator: Accumulator = {\n text: \"\",\n tools: new Map(),\n order: [],\n usage: {},\n finishReason: Option.none(),\n}\n\n/** State threaded through the fold plus the events the step produced. */\ntype Step = readonly [Accumulator, ReadonlyArray<TurnEvent>]\n\nconst usageFrom = (wire: typeof WireUsage.Type): Usage => ({\n ...(wire.prompt_tokens !== undefined && { input_tokens: wire.prompt_tokens }),\n ...(wire.completion_tokens !== undefined && { output_tokens: wire.completion_tokens }),\n ...(wire.total_tokens !== undefined && { total_tokens: wire.total_tokens }),\n})\n\nconst reasonToStop = Match.type<string>().pipe(\n Match.when(\"stop\", () => \"stop\" as const),\n Match.when(\"length\", () => \"max_tokens\" as const),\n Match.when(\"tool_calls\", () => \"tool_calls\" as const),\n Match.when(\"content_filter\", () => \"content_filter\" as const),\n Match.orElse(() => \"stop\" as const),\n)\n\nconst stopReasonOf = (acc: Accumulator): StopReason =>\n Option.match(acc.finishReason, {\n onNone: () => (acc.tools.size > 0 ? \"tool_calls\" : \"stop\"),\n onSome: reasonToStop,\n })\n\nconst withTool = (acc: Accumulator, index: number, tool: ToolAcc, isNew: boolean): Accumulator => ({\n ...acc,\n tools: new Map(acc.tools).set(index, tool),\n order: isNew ? [...acc.order, index] : acc.order,\n})\n\nconst applyContent = (acc: Accumulator, delta: typeof WireDelta.Type | undefined): Step => {\n const content = delta?.content\n return content !== undefined && content !== null && content.length > 0\n ? [{ ...acc, text: acc.text + content }, [TurnEvent.TextDelta({ text: content })]]\n : [acc, []]\n}\n\nconst applyToolCall = (acc: Accumulator, tc: typeof WireToolCall.Type, position: number): Step => {\n const index = tc.index ?? position\n const argsDelta = tc.function?.arguments ?? \"\"\n const existing = acc.tools.get(index)\n if (existing === undefined) {\n // Only `index` is guaranteed on a streaming tool-call chunk, so the id may\n // be absent. The synthesised fallback is 9-char alphanumeric: valid on every\n // chat-completions wire (Mistral additionally requires `^[a-zA-Z0-9]{9}$`),\n // and it is replayed on the next turn.\n const call_id = tc.id ?? String(index).padStart(9, \"0\")\n const name = tc.function?.name ?? \"\"\n return [\n withTool(acc, index, { call_id, name, arguments: argsDelta }, true),\n [\n TurnEvent.ToolCallStart({ call_id, name }),\n ...(argsDelta.length > 0\n ? [TurnEvent.ToolCallArgsDelta({ call_id, delta: argsDelta })]\n : []),\n ],\n ]\n }\n return argsDelta.length > 0\n ? [\n withTool(acc, index, { ...existing, arguments: existing.arguments + argsDelta }, false),\n [TurnEvent.ToolCallArgsDelta({ call_id: existing.call_id, delta: argsDelta })],\n ]\n : [acc, []]\n}\n\nconst chain = (step: Step, next: (acc: Accumulator) => Step): Step => {\n const [acc, events] = step\n const [acc2, more] = next(acc)\n return [acc2, [...events, ...more]]\n}\n\nconst applyChoice = (step: Step, choice: typeof WireChoice.Type): Step => {\n const withContent = chain(step, (acc) => applyContent(acc, choice.delta))\n const withTools = Arr.reduce(choice.delta?.tool_calls ?? [], withContent, (s: Step, tc, i) =>\n chain(s, (acc) => applyToolCall(acc, tc, i)),\n )\n const reason = choice.finish_reason\n return reason !== undefined && reason !== null\n ? [{ ...withTools[0], finishReason: Option.some(reason) }, withTools[1]]\n : withTools\n}\n\n/**\n * Fold one decoded chunk into the accumulator, returning the next state and the\n * `TurnEvent`s the chunk produced.\n */\nexport const applyChunk = (acc: Accumulator, chunk: WireChunk): Step => {\n const afterChoices = Arr.reduce(chunk.choices ?? [], [acc, []] as Step, applyChoice)\n const usage = chunk.usage\n return usage !== undefined && usage !== null\n ? chain(afterChoices, (a) => {\n const u = usageFrom(usage)\n return [{ ...a, usage: u }, [TurnEvent.UsageUpdate({ usage: u })]]\n })\n : afterChoices\n}\n\n/** Assemble the terminal `Turn` from the accumulated state. */\nexport const accumulatorToTurn = (acc: Accumulator): Turn => {\n const message: ReadonlyArray<HistoryItem> =\n acc.text.length > 0\n ? [{ type: \"message\", role: \"assistant\", content: [{ type: \"output_text\", text: acc.text }] }]\n : []\n const toolCalls: ReadonlyArray<HistoryItem> = acc.order.flatMap((index) => {\n const t = acc.tools.get(index)\n return t === undefined\n ? []\n : [\n {\n type: \"function_call\" as const,\n call_id: t.call_id,\n name: t.name,\n arguments: t.arguments,\n },\n ]\n })\n return {\n items: [...message, ...toolCalls],\n usage: acc.usage,\n stop_reason: stopReasonOf(acc),\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ import * as AiError from "@effect-uai/core/AiError";
2
+
3
+ //#region src/http.d.ts
4
+ declare namespace http_d_exports {
5
+ export { httpStatusError, transportFailure };
6
+ }
7
+ /** Map an HTTP status from a chat-completions endpoint to an `AiError` variant. */
8
+ declare const httpStatusError: (provider: string, status: number, body: string) => AiError.AiError;
9
+ declare const transportFailure: (provider: string, cause: unknown) => AiError.AiError;
10
+ //#endregion
11
+ export { httpStatusError, http_d_exports as t, transportFailure };
12
+ //# sourceMappingURL=http.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.d.mts","names":[],"sources":["../src/http.ts"],"mappings":";;;;;;;cAIa,eAAA,GAAmB,QAAA,UAAkB,MAAA,UAAgB,IAAA,aAAe,OAAA,CAAQ,OAAA;AAAA,cA2B5E,gBAAA,GAAoB,QAAA,UAAkB,KAAA,cAAiB,OAAA,CAAQ,OAAA"}
package/dist/http.mjs ADDED
@@ -0,0 +1,46 @@
1
+ import { t as __exportAll } from "./chunk-CfYAbeIz.mjs";
2
+ import { Match } from "effect";
3
+ import * as AiError from "@effect-uai/core/AiError";
4
+ //#region src/http.ts
5
+ var http_exports = /* @__PURE__ */ __exportAll({
6
+ httpStatusError: () => httpStatusError,
7
+ transportFailure: () => transportFailure
8
+ });
9
+ /** Map an HTTP status from a chat-completions endpoint to an `AiError` variant. */
10
+ const httpStatusError = (provider, status, body) => Match.value(status).pipe(Match.when(429, () => new AiError.RateLimited({
11
+ provider,
12
+ raw: body
13
+ })), Match.whenOr(408, 504, () => new AiError.Timeout({
14
+ provider,
15
+ raw: body
16
+ })), Match.when(401, () => new AiError.AuthFailed({
17
+ provider,
18
+ subtype: "auth",
19
+ raw: body
20
+ })), Match.when(403, () => new AiError.AuthFailed({
21
+ provider,
22
+ subtype: "permission",
23
+ raw: body
24
+ })), Match.when(402, () => new AiError.AuthFailed({
25
+ provider,
26
+ subtype: "billing",
27
+ raw: body
28
+ })), Match.when(413, () => new AiError.ContextLengthExceeded({
29
+ provider,
30
+ raw: body
31
+ })), Match.when((n) => n >= 500, (n) => new AiError.Unavailable({
32
+ provider,
33
+ status: n,
34
+ raw: body
35
+ })), Match.orElse(() => new AiError.InvalidRequest({
36
+ provider,
37
+ raw: body
38
+ })));
39
+ const transportFailure = (provider, cause) => new AiError.Unavailable({
40
+ provider,
41
+ raw: cause
42
+ });
43
+ //#endregion
44
+ export { httpStatusError, http_exports as t, transportFailure };
45
+
46
+ //# sourceMappingURL=http.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"http.mjs","names":[],"sources":["../src/http.ts"],"sourcesContent":["import { Match } from \"effect\"\nimport * as AiError from \"@effect-uai/core/AiError\"\n\n/** Map an HTTP status from a chat-completions endpoint to an `AiError` variant. */\nexport const httpStatusError = (provider: string, status: number, body: string): AiError.AiError =>\n Match.value(status).pipe(\n Match.when(429, (): AiError.AiError => new AiError.RateLimited({ provider, raw: body })),\n Match.whenOr(408, 504, (): AiError.AiError => new AiError.Timeout({ provider, raw: body })),\n Match.when(\n 401,\n (): AiError.AiError => new AiError.AuthFailed({ provider, subtype: \"auth\", raw: body }),\n ),\n Match.when(\n 403,\n (): AiError.AiError => new AiError.AuthFailed({ provider, subtype: \"permission\", raw: body }),\n ),\n Match.when(\n 402,\n (): AiError.AiError => new AiError.AuthFailed({ provider, subtype: \"billing\", raw: body }),\n ),\n Match.when(\n 413,\n (): AiError.AiError => new AiError.ContextLengthExceeded({ provider, raw: body }),\n ),\n Match.when(\n (n) => n >= 500,\n (n): AiError.AiError => new AiError.Unavailable({ provider, status: n, raw: body }),\n ),\n Match.orElse((): AiError.AiError => new AiError.InvalidRequest({ provider, raw: body })),\n )\n\nexport const transportFailure = (provider: string, cause: unknown): AiError.AiError =>\n new AiError.Unavailable({ provider, raw: cause })\n"],"mappings":";;;;;;;;;AAIA,MAAa,mBAAmB,UAAkB,QAAgB,SAChE,MAAM,MAAM,OAAO,CAAC,KAClB,MAAM,KAAK,WAA4B,IAAI,QAAQ,YAAY;CAAE;CAAU,KAAK;CAAM,CAAC,CAAC,EACxF,MAAM,OAAO,KAAK,WAA4B,IAAI,QAAQ,QAAQ;CAAE;CAAU,KAAK;CAAM,CAAC,CAAC,EAC3F,MAAM,KACJ,WACuB,IAAI,QAAQ,WAAW;CAAE;CAAU,SAAS;CAAQ,KAAK;CAAM,CAAC,CACxF,EACD,MAAM,KACJ,WACuB,IAAI,QAAQ,WAAW;CAAE;CAAU,SAAS;CAAc,KAAK;CAAM,CAAC,CAC9F,EACD,MAAM,KACJ,WACuB,IAAI,QAAQ,WAAW;CAAE;CAAU,SAAS;CAAW,KAAK;CAAM,CAAC,CAC3F,EACD,MAAM,KACJ,WACuB,IAAI,QAAQ,sBAAsB;CAAE;CAAU,KAAK;CAAM,CAAC,CAClF,EACD,MAAM,MACH,MAAM,KAAK,MACX,MAAuB,IAAI,QAAQ,YAAY;CAAE;CAAU,QAAQ;CAAG,KAAK;CAAM,CAAC,CACpF,EACD,MAAM,aAA8B,IAAI,QAAQ,eAAe;CAAE;CAAU,KAAK;CAAM,CAAC,CAAC,CACzF;AAEH,MAAa,oBAAoB,UAAkB,UACjD,IAAI,QAAQ,YAAY;CAAE;CAAU,KAAK;CAAO,CAAC"}
@@ -0,0 +1,4 @@
1
+ import { t as ChatCompletions_d_exports } from "./ChatCompletions.mjs";
2
+ import { t as codec_d_exports } from "./codec.mjs";
3
+ import { t as http_d_exports } from "./http.mjs";
4
+ export { ChatCompletions_d_exports as ChatCompletions, codec_d_exports as codec, http_d_exports as http };
package/dist/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ import { t as codec_exports } from "./codec.mjs";
2
+ import { t as http_exports } from "./http.mjs";
3
+ import { t as ChatCompletions_exports } from "./ChatCompletions.mjs";
4
+ export { ChatCompletions_exports as ChatCompletions, codec_exports as codec, http_exports as http };
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@effect-uai/chat-completions",
3
+ "version": "0.11.0",
4
+ "description": "Reusable OpenAI Chat Completions (`/chat/completions`) LanguageModel base for @effect-uai/core. Point it at any compatible gateway (OpenRouter, Groq, Together, self-hosted).",
5
+ "keywords": [
6
+ "ai",
7
+ "chat-completions",
8
+ "effect",
9
+ "gateway",
10
+ "llm",
11
+ "openai",
12
+ "openrouter"
13
+ ],
14
+ "homepage": "https://github.com/betalyra/effect-uai/tree/main/packages/providers/chat-completions",
15
+ "bugs": {
16
+ "url": "https://github.com/betalyra/effect-uai/issues"
17
+ },
18
+ "license": "MIT",
19
+ "author": "Betalyra",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/betalyra/effect-uai",
23
+ "directory": "packages/providers/chat-completions"
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "type": "module",
32
+ "main": "./dist/index.mjs",
33
+ "types": "./dist/index.d.mts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.mts",
37
+ "import": "./dist/index.mjs"
38
+ },
39
+ "./ChatCompletions": {
40
+ "types": "./dist/ChatCompletions.d.mts",
41
+ "import": "./dist/ChatCompletions.mjs"
42
+ },
43
+ "./codec": {
44
+ "types": "./dist/codec.d.mts",
45
+ "import": "./dist/codec.mjs"
46
+ },
47
+ "./http": {
48
+ "types": "./dist/http.d.mts",
49
+ "import": "./dist/http.mjs"
50
+ }
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "devDependencies": {
56
+ "effect": "4.0.0-beta.94",
57
+ "typescript": "^6.0.3",
58
+ "@effect-uai/core": "0.11.1"
59
+ },
60
+ "peerDependencies": {
61
+ "@effect-uai/core": ">=0.2.0 <1",
62
+ "effect": ">=4.0.0-beta.94 <5.0.0"
63
+ },
64
+ "scripts": {
65
+ "build": "tsdown",
66
+ "typecheck": "tsc --noEmit"
67
+ }
68
+ }
@@ -0,0 +1,159 @@
1
+ import { Effect, Layer, Option, Redacted, Result, Schema, Stream } from "effect"
2
+ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
3
+ import * as AiError from "@effect-uai/core/AiError"
4
+ import {
5
+ type CommonRequest,
6
+ LanguageModel,
7
+ type LanguageModelService,
8
+ turnFromStream,
9
+ } from "@effect-uai/core/LanguageModel"
10
+ import * as SSE from "@effect-uai/core/SSE"
11
+ import { descriptorsOf } from "@effect-uai/core/Tool"
12
+ import { TurnEvent } from "@effect-uai/core/Turn"
13
+ import {
14
+ type Accumulator,
15
+ type WireChunk,
16
+ accumulatorToTurn,
17
+ applyChunk,
18
+ decodeChunk,
19
+ emptyAccumulator,
20
+ itemsToMessages,
21
+ responseFormatWire,
22
+ toolChoiceWire,
23
+ toolsWire,
24
+ } from "./codec.js"
25
+ import { httpStatusError, transportFailure } from "./http.js"
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Config
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export type ChatConfig = {
32
+ readonly apiKey: Redacted.Redacted
33
+ /** Provider base URL, e.g. `https://openrouter.ai/api/v1`. No trailing slash. */
34
+ readonly baseUrl: string
35
+ /** Name used to tag `AiError`s from this provider. */
36
+ readonly provider: string
37
+ /** Request path appended to `baseUrl`. Defaults to `/chat/completions`. */
38
+ readonly path?: string
39
+ /**
40
+ * Override the auth header. Defaults to `Authorization: Bearer <apiKey>`.
41
+ * Supply this for gateways that authenticate differently (e.g. Azure `api-key`).
42
+ */
43
+ readonly authHeader?: (
44
+ request: HttpClientRequest.HttpClientRequest,
45
+ ) => HttpClientRequest.HttpClientRequest
46
+ /** Extra headers merged into every request (e.g. OpenRouter `HTTP-Referer`). */
47
+ readonly extraHeaders?: Record<string, string>
48
+ /** Provider-specific request-body fields merged into the wire body. */
49
+ readonly extraBody?: (request: CommonRequest) => Record<string, unknown>
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Request body
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const buildRequestBody = (cfg: ChatConfig, request: CommonRequest): Record<string, unknown> => {
57
+ const tools = toolsWire(descriptorsOf(request.tools))
58
+ return {
59
+ model: request.model,
60
+ stream: true,
61
+ messages: itemsToMessages(request.history),
62
+ ...(request.temperature !== undefined && { temperature: request.temperature }),
63
+ ...(request.topP !== undefined && { top_p: request.topP }),
64
+ ...(request.maxOutputTokens !== undefined && { max_tokens: request.maxOutputTokens }),
65
+ ...(Option.isSome(tools) && { tools: tools.value }),
66
+ ...(request.toolChoice !== undefined && { tool_choice: toolChoiceWire(request.toolChoice) }),
67
+ ...(request.structured !== undefined && {
68
+ response_format: responseFormatWire(request.structured),
69
+ }),
70
+ ...(cfg.extraBody?.(request) ?? {}),
71
+ }
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // SSE → WireChunk
76
+ // ---------------------------------------------------------------------------
77
+
78
+ const parseJsonUnknown = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
79
+
80
+ /**
81
+ * Decode one SSE event's `data` payload into a `WireChunk`. Returns `None` for
82
+ * the `[DONE]` sentinel and for any payload that isn't a chunk we model, so a
83
+ * stray keep-alive never aborts the turn.
84
+ */
85
+ const decodeEvent = (data: string): Effect.Effect<Option.Option<WireChunk>> =>
86
+ data.trim() === "[DONE]"
87
+ ? Effect.succeedNone
88
+ : parseJsonUnknown(data).pipe(Effect.flatMap(decodeChunk), Effect.option)
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Stream
92
+ // ---------------------------------------------------------------------------
93
+
94
+ const buildStream = (cfg: ChatConfig) => {
95
+ const url = `${cfg.baseUrl}${cfg.path ?? "/chat/completions"}`
96
+ const auth = cfg.authHeader ?? HttpClientRequest.bearerToken(cfg.apiKey)
97
+ return (
98
+ request: CommonRequest,
99
+ ): Stream.Stream<TurnEvent, AiError.AiError, HttpClient.HttpClient> =>
100
+ Stream.unwrap(
101
+ Effect.gen(function* () {
102
+ const client = yield* HttpClient.HttpClient
103
+ const httpRequest = HttpClientRequest.post(url).pipe(
104
+ auth,
105
+ HttpClientRequest.setHeaders(cfg.extraHeaders ?? {}),
106
+ HttpClientRequest.bodyJsonUnsafe(buildRequestBody(cfg, request)),
107
+ HttpClientRequest.accept("text/event-stream"),
108
+ )
109
+ const response = yield* client
110
+ .execute(httpRequest)
111
+ .pipe(Effect.mapError((cause) => transportFailure(cfg.provider, cause)))
112
+ if (response.status >= 400) {
113
+ const text = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
114
+ return Stream.fail(httpStatusError(cfg.provider, response.status, text))
115
+ }
116
+
117
+ return response.stream.pipe(
118
+ Stream.mapError(
119
+ (cause): AiError.AiError =>
120
+ new AiError.Unavailable({ provider: cfg.provider, raw: cause }),
121
+ ),
122
+ SSE.fromBytes,
123
+ Stream.mapEffect((ev) => decodeEvent(ev.data)),
124
+ Stream.filterMap((chunk) =>
125
+ Option.isSome(chunk) ? Result.succeed(chunk.value) : Result.failVoid,
126
+ ),
127
+ Stream.mapAccum((): Accumulator => emptyAccumulator, applyChunk, {
128
+ // `onHalt` also fires on upstream failure and truncated streams, so
129
+ // only emit `TurnComplete` once a `finish_reason` was observed.
130
+ onHalt: (acc) =>
131
+ Option.isSome(acc.finishReason)
132
+ ? [TurnEvent.TurnComplete({ turn: accumulatorToTurn(acc) })]
133
+ : [],
134
+ }),
135
+ )
136
+ }),
137
+ )
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Constructors
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /** Build a generic chat-completions `LanguageModelService`. */
145
+ export const make = (
146
+ cfg: ChatConfig,
147
+ ): Effect.Effect<LanguageModelService, never, HttpClient.HttpClient> =>
148
+ Effect.map(HttpClient.HttpClient, (client) => {
149
+ const streamTurn: LanguageModelService["streamTurn"] = (request) =>
150
+ buildStream(cfg)(request).pipe(Stream.provideService(HttpClient.HttpClient, client))
151
+ return {
152
+ streamTurn,
153
+ turn: turnFromStream(streamTurn),
154
+ }
155
+ })
156
+
157
+ /** Layer registering the generic `LanguageModel` tag for a compatible gateway. */
158
+ export const layer = (cfg: ChatConfig): Layer.Layer<LanguageModel, never, HttpClient.HttpClient> =>
159
+ Layer.effect(LanguageModel, make(cfg))
package/src/codec.ts ADDED
@@ -0,0 +1,338 @@
1
+ import { Array as Arr, Encoding, Match, Option, Schema } from "effect"
2
+ import type {
3
+ ContentBlock,
4
+ HistoryItem,
5
+ InputImage,
6
+ StopReason,
7
+ Usage,
8
+ } from "@effect-uai/core/Items"
9
+ import type { StructuredFormat } from "@effect-uai/core/StructuredFormat"
10
+ import type { ToolDescriptor } from "@effect-uai/core/Tool"
11
+ import type { Turn } from "@effect-uai/core/Turn"
12
+ import { TurnEvent } from "@effect-uai/core/Turn"
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // History → chat `messages`
16
+ //
17
+ // The OpenAI Chat Completions dialect: a flat list of `{ role, content, ... }`
18
+ // objects where an assistant turn's tool calls live on the assistant message
19
+ // (`tool_calls`) and each tool result is its own
20
+ // `{ role: "tool", tool_call_id, content }` message. Our history is a flatter
21
+ // item list (assistant message, then separate `function_call` items), so we
22
+ // fold consecutive `function_call`s onto the preceding assistant message.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ type WireMessage = {
26
+ role: string
27
+ content: string | ReadonlyArray<Record<string, unknown>>
28
+ tool_calls?: ReadonlyArray<Record<string, unknown>>
29
+ tool_call_id?: string
30
+ }
31
+
32
+ /** A `data:` URI for inline image bytes; URLs pass through untouched. */
33
+ const imageSourceToUrl = Match.type<InputImage["source"]>().pipe(
34
+ Match.tag("url", (s) => s.url),
35
+ Match.tag("base64", (s) => `data:${s.mimeType};base64,${s.base64}`),
36
+ Match.tag("bytes", (s) => `data:${s.mimeType};base64,${Encoding.encodeBase64(s.bytes)}`),
37
+ Match.exhaustive,
38
+ )
39
+
40
+ const blockToPart = Match.type<ContentBlock>().pipe(
41
+ Match.discriminatorsExhaustive("type")({
42
+ input_text: (b) => ({ type: "text", text: b.text }),
43
+ output_text: (b) => ({ type: "text", text: b.text }),
44
+ refusal: (b) => ({ type: "text", text: b.text }),
45
+ input_image: (b) => ({ type: "image_url", image_url: { url: imageSourceToUrl(b.source) } }),
46
+ }),
47
+ )
48
+
49
+ const isTextBlock = (b: ContentBlock): boolean =>
50
+ b.type === "input_text" || b.type === "output_text" || b.type === "refusal"
51
+
52
+ const textOfBlock = (b: ContentBlock): string => (b.type === "input_image" ? "" : b.text)
53
+
54
+ /**
55
+ * Collapse a message's content to a plain string when it is text-only (the
56
+ * common case), otherwise emit the multimodal parts array.
57
+ */
58
+ const encodeContent = (
59
+ content: ReadonlyArray<ContentBlock>,
60
+ ): string | ReadonlyArray<Record<string, unknown>> =>
61
+ Arr.every(content, isTextBlock) ? content.map(textOfBlock).join("") : content.map(blockToPart)
62
+
63
+ const toolCallWire = (call_id: string, name: string, args: string): Record<string, unknown> => ({
64
+ id: call_id,
65
+ type: "function",
66
+ function: { name, arguments: args },
67
+ })
68
+
69
+ const appendToolCall = (
70
+ acc: ReadonlyArray<WireMessage>,
71
+ call_id: string,
72
+ name: string,
73
+ args: string,
74
+ ): ReadonlyArray<WireMessage> => {
75
+ const last = acc[acc.length - 1]
76
+ const wire = toolCallWire(call_id, name, args)
77
+ // Attach to the immediately-preceding assistant message (not a tool result),
78
+ // matching the shape where tool calls ride the assistant turn.
79
+ if (last !== undefined && last.role === "assistant" && last.tool_call_id === undefined) {
80
+ return [...acc.slice(0, -1), { ...last, tool_calls: [...(last.tool_calls ?? []), wire] }]
81
+ }
82
+ return [...acc, { role: "assistant", content: "", tool_calls: [wire] }]
83
+ }
84
+
85
+ const foldItem = (acc: ReadonlyArray<WireMessage>, item: HistoryItem): ReadonlyArray<WireMessage> =>
86
+ Match.value(item).pipe(
87
+ Match.discriminatorsExhaustive("type")({
88
+ message: (m) => [...acc, { role: m.role, content: encodeContent(m.content) }],
89
+ function_call: (f) => appendToolCall(acc, f.call_id, f.name, f.arguments),
90
+ function_call_output: (o) => [
91
+ ...acc,
92
+ { role: "tool", tool_call_id: o.call_id, content: o.output },
93
+ ],
94
+ // Reasoning items are not replayed to the chat-completions endpoint.
95
+ reasoning: () => acc,
96
+ }),
97
+ )
98
+
99
+ /** Convert our `HistoryItem[]` history into chat `messages`. */
100
+ export const itemsToMessages = (items: ReadonlyArray<HistoryItem>): ReadonlyArray<WireMessage> =>
101
+ Arr.reduce(items, [] as ReadonlyArray<WireMessage>, foldItem)
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // tools / tool_choice / response_format
105
+ // ---------------------------------------------------------------------------
106
+
107
+ export const toolsWire = (
108
+ descriptors: ReadonlyArray<ToolDescriptor>,
109
+ ): Option.Option<ReadonlyArray<Record<string, unknown>>> =>
110
+ descriptors.length > 0
111
+ ? Option.some(
112
+ descriptors.map((t) => ({
113
+ type: "function",
114
+ function: {
115
+ name: t.name,
116
+ description: t.description,
117
+ parameters: t.inputSchema,
118
+ ...(t.strict !== undefined && { strict: t.strict }),
119
+ },
120
+ })),
121
+ )
122
+ : Option.none()
123
+
124
+ type ToolChoice =
125
+ | "auto"
126
+ | "required"
127
+ | "none"
128
+ | { readonly type: "function"; readonly name: string }
129
+
130
+ export const toolChoiceWire = (choice: ToolChoice): string | Record<string, unknown> =>
131
+ Match.value(choice).pipe(
132
+ Match.when("auto", () => "auto" as const),
133
+ Match.when("required", () => "required" as const),
134
+ Match.when("none", () => "none" as const),
135
+ Match.orElse((c) => ({ type: "function", function: { name: c.name } })),
136
+ )
137
+
138
+ export const responseFormatWire = (
139
+ structured: StructuredFormat<unknown>,
140
+ ): Record<string, unknown> => ({
141
+ type: "json_schema",
142
+ json_schema: {
143
+ name: structured.name,
144
+ schema: structured.schema["~standard"].jsonSchema.input({ target: "draft-2020-12" }),
145
+ ...(structured.strict !== undefined && { strict: structured.strict }),
146
+ },
147
+ })
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Streaming decode: chat.completion.chunk → TurnEvent
151
+ // ---------------------------------------------------------------------------
152
+
153
+ const WireFunction = Schema.Struct({
154
+ name: Schema.optional(Schema.NullOr(Schema.String)),
155
+ arguments: Schema.optional(Schema.NullOr(Schema.String)),
156
+ })
157
+
158
+ const WireToolCall = Schema.Struct({
159
+ index: Schema.optional(Schema.Number),
160
+ id: Schema.optional(Schema.NullOr(Schema.String)),
161
+ function: Schema.optional(WireFunction),
162
+ })
163
+
164
+ const WireDelta = Schema.Struct({
165
+ role: Schema.optional(Schema.NullOr(Schema.String)),
166
+ content: Schema.optional(Schema.NullOr(Schema.String)),
167
+ tool_calls: Schema.optional(Schema.NullOr(Schema.Array(WireToolCall))),
168
+ })
169
+
170
+ const WireChoice = Schema.Struct({
171
+ index: Schema.optional(Schema.Number),
172
+ delta: Schema.optional(WireDelta),
173
+ finish_reason: Schema.optional(Schema.NullOr(Schema.String)),
174
+ })
175
+
176
+ const WireUsage = Schema.Struct({
177
+ prompt_tokens: Schema.optional(Schema.Number),
178
+ completion_tokens: Schema.optional(Schema.Number),
179
+ total_tokens: Schema.optional(Schema.Number),
180
+ })
181
+
182
+ export const WireChunk = Schema.Struct({
183
+ choices: Schema.optional(Schema.Array(WireChoice)),
184
+ usage: Schema.optional(Schema.NullOr(WireUsage)),
185
+ })
186
+ export type WireChunk = typeof WireChunk.Type
187
+
188
+ export const decodeChunk = Schema.decodeUnknownEffect(WireChunk)
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Accumulator
192
+ // ---------------------------------------------------------------------------
193
+
194
+ type ToolAcc = {
195
+ readonly call_id: string
196
+ readonly name: string
197
+ readonly arguments: string
198
+ }
199
+
200
+ export type Accumulator = {
201
+ readonly text: string
202
+ /** Tool calls keyed by their wire `index` (defaults to position when absent). */
203
+ readonly tools: ReadonlyMap<number, ToolAcc>
204
+ readonly order: ReadonlyArray<number>
205
+ readonly usage: Usage
206
+ readonly finishReason: Option.Option<string>
207
+ }
208
+
209
+ export const emptyAccumulator: Accumulator = {
210
+ text: "",
211
+ tools: new Map(),
212
+ order: [],
213
+ usage: {},
214
+ finishReason: Option.none(),
215
+ }
216
+
217
+ /** State threaded through the fold plus the events the step produced. */
218
+ type Step = readonly [Accumulator, ReadonlyArray<TurnEvent>]
219
+
220
+ const usageFrom = (wire: typeof WireUsage.Type): Usage => ({
221
+ ...(wire.prompt_tokens !== undefined && { input_tokens: wire.prompt_tokens }),
222
+ ...(wire.completion_tokens !== undefined && { output_tokens: wire.completion_tokens }),
223
+ ...(wire.total_tokens !== undefined && { total_tokens: wire.total_tokens }),
224
+ })
225
+
226
+ const reasonToStop = Match.type<string>().pipe(
227
+ Match.when("stop", () => "stop" as const),
228
+ Match.when("length", () => "max_tokens" as const),
229
+ Match.when("tool_calls", () => "tool_calls" as const),
230
+ Match.when("content_filter", () => "content_filter" as const),
231
+ Match.orElse(() => "stop" as const),
232
+ )
233
+
234
+ const stopReasonOf = (acc: Accumulator): StopReason =>
235
+ Option.match(acc.finishReason, {
236
+ onNone: () => (acc.tools.size > 0 ? "tool_calls" : "stop"),
237
+ onSome: reasonToStop,
238
+ })
239
+
240
+ const withTool = (acc: Accumulator, index: number, tool: ToolAcc, isNew: boolean): Accumulator => ({
241
+ ...acc,
242
+ tools: new Map(acc.tools).set(index, tool),
243
+ order: isNew ? [...acc.order, index] : acc.order,
244
+ })
245
+
246
+ const applyContent = (acc: Accumulator, delta: typeof WireDelta.Type | undefined): Step => {
247
+ const content = delta?.content
248
+ return content !== undefined && content !== null && content.length > 0
249
+ ? [{ ...acc, text: acc.text + content }, [TurnEvent.TextDelta({ text: content })]]
250
+ : [acc, []]
251
+ }
252
+
253
+ const applyToolCall = (acc: Accumulator, tc: typeof WireToolCall.Type, position: number): Step => {
254
+ const index = tc.index ?? position
255
+ const argsDelta = tc.function?.arguments ?? ""
256
+ const existing = acc.tools.get(index)
257
+ if (existing === undefined) {
258
+ // Only `index` is guaranteed on a streaming tool-call chunk, so the id may
259
+ // be absent. The synthesised fallback is 9-char alphanumeric: valid on every
260
+ // chat-completions wire (Mistral additionally requires `^[a-zA-Z0-9]{9}$`),
261
+ // and it is replayed on the next turn.
262
+ const call_id = tc.id ?? String(index).padStart(9, "0")
263
+ const name = tc.function?.name ?? ""
264
+ return [
265
+ withTool(acc, index, { call_id, name, arguments: argsDelta }, true),
266
+ [
267
+ TurnEvent.ToolCallStart({ call_id, name }),
268
+ ...(argsDelta.length > 0
269
+ ? [TurnEvent.ToolCallArgsDelta({ call_id, delta: argsDelta })]
270
+ : []),
271
+ ],
272
+ ]
273
+ }
274
+ return argsDelta.length > 0
275
+ ? [
276
+ withTool(acc, index, { ...existing, arguments: existing.arguments + argsDelta }, false),
277
+ [TurnEvent.ToolCallArgsDelta({ call_id: existing.call_id, delta: argsDelta })],
278
+ ]
279
+ : [acc, []]
280
+ }
281
+
282
+ const chain = (step: Step, next: (acc: Accumulator) => Step): Step => {
283
+ const [acc, events] = step
284
+ const [acc2, more] = next(acc)
285
+ return [acc2, [...events, ...more]]
286
+ }
287
+
288
+ const applyChoice = (step: Step, choice: typeof WireChoice.Type): Step => {
289
+ const withContent = chain(step, (acc) => applyContent(acc, choice.delta))
290
+ const withTools = Arr.reduce(choice.delta?.tool_calls ?? [], withContent, (s: Step, tc, i) =>
291
+ chain(s, (acc) => applyToolCall(acc, tc, i)),
292
+ )
293
+ const reason = choice.finish_reason
294
+ return reason !== undefined && reason !== null
295
+ ? [{ ...withTools[0], finishReason: Option.some(reason) }, withTools[1]]
296
+ : withTools
297
+ }
298
+
299
+ /**
300
+ * Fold one decoded chunk into the accumulator, returning the next state and the
301
+ * `TurnEvent`s the chunk produced.
302
+ */
303
+ export const applyChunk = (acc: Accumulator, chunk: WireChunk): Step => {
304
+ const afterChoices = Arr.reduce(chunk.choices ?? [], [acc, []] as Step, applyChoice)
305
+ const usage = chunk.usage
306
+ return usage !== undefined && usage !== null
307
+ ? chain(afterChoices, (a) => {
308
+ const u = usageFrom(usage)
309
+ return [{ ...a, usage: u }, [TurnEvent.UsageUpdate({ usage: u })]]
310
+ })
311
+ : afterChoices
312
+ }
313
+
314
+ /** Assemble the terminal `Turn` from the accumulated state. */
315
+ export const accumulatorToTurn = (acc: Accumulator): Turn => {
316
+ const message: ReadonlyArray<HistoryItem> =
317
+ acc.text.length > 0
318
+ ? [{ type: "message", role: "assistant", content: [{ type: "output_text", text: acc.text }] }]
319
+ : []
320
+ const toolCalls: ReadonlyArray<HistoryItem> = acc.order.flatMap((index) => {
321
+ const t = acc.tools.get(index)
322
+ return t === undefined
323
+ ? []
324
+ : [
325
+ {
326
+ type: "function_call" as const,
327
+ call_id: t.call_id,
328
+ name: t.name,
329
+ arguments: t.arguments,
330
+ },
331
+ ]
332
+ })
333
+ return {
334
+ items: [...message, ...toolCalls],
335
+ usage: acc.usage,
336
+ stop_reason: stopReasonOf(acc),
337
+ }
338
+ }
package/src/http.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { Match } from "effect"
2
+ import * as AiError from "@effect-uai/core/AiError"
3
+
4
+ /** Map an HTTP status from a chat-completions endpoint to an `AiError` variant. */
5
+ export const httpStatusError = (provider: string, status: number, body: string): AiError.AiError =>
6
+ Match.value(status).pipe(
7
+ Match.when(429, (): AiError.AiError => new AiError.RateLimited({ provider, raw: body })),
8
+ Match.whenOr(408, 504, (): AiError.AiError => new AiError.Timeout({ provider, raw: body })),
9
+ Match.when(
10
+ 401,
11
+ (): AiError.AiError => new AiError.AuthFailed({ provider, subtype: "auth", raw: body }),
12
+ ),
13
+ Match.when(
14
+ 403,
15
+ (): AiError.AiError => new AiError.AuthFailed({ provider, subtype: "permission", raw: body }),
16
+ ),
17
+ Match.when(
18
+ 402,
19
+ (): AiError.AiError => new AiError.AuthFailed({ provider, subtype: "billing", raw: body }),
20
+ ),
21
+ Match.when(
22
+ 413,
23
+ (): AiError.AiError => new AiError.ContextLengthExceeded({ provider, raw: body }),
24
+ ),
25
+ Match.when(
26
+ (n) => n >= 500,
27
+ (n): AiError.AiError => new AiError.Unavailable({ provider, status: n, raw: body }),
28
+ ),
29
+ Match.orElse((): AiError.AiError => new AiError.InvalidRequest({ provider, raw: body })),
30
+ )
31
+
32
+ export const transportFailure = (provider: string, cause: unknown): AiError.AiError =>
33
+ new AiError.Unavailable({ provider, raw: cause })
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * as ChatCompletions from "./ChatCompletions.js"
2
+ export * as codec from "./codec.js"
3
+ export * as http from "./http.js"