@xandreed/plugin-models 0.3.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 +21 -0
- package/README.md +5 -0
- package/dist/auth/anthropicOAuth.d.ts +50 -0
- package/dist/auth/anthropicOAuth.js +90 -0
- package/dist/auth/anthropicOAuth.test.d.ts +1 -0
- package/dist/auth/localAuth.d.ts +8 -0
- package/dist/auth/localAuth.js +90 -0
- package/dist/auth/localAuth.test.d.ts +1 -0
- package/dist/auth/openAiCodexOAuth.d.ts +25 -0
- package/dist/auth/openAiCodexOAuth.js +58 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +53 -0
- package/dist/llm/compat.d.ts +77 -0
- package/dist/llm/compat.js +215 -0
- package/dist/llm/compat.test.d.ts +1 -0
- package/dist/llm/modelCatalog.d.ts +10 -0
- package/dist/llm/modelCatalog.js +58 -0
- package/dist/llm/modelCatalog.test.d.ts +1 -0
- package/dist/llm/openAiCodex.d.ts +18 -0
- package/dist/llm/openAiCodex.js +99 -0
- package/dist/llm/openAiCodexWebSocket.d.ts +14 -0
- package/dist/llm/openAiCodexWebSocket.js +126 -0
- package/dist/llm/providers.d.ts +37 -0
- package/dist/llm/providers.js +114 -0
- package/dist/llm/providers.test.d.ts +1 -0
- package/dist/llm/retry.d.ts +54 -0
- package/dist/llm/retry.js +61 -0
- package/dist/llm/retry.test.d.ts +1 -0
- package/dist/llm/router.d.ts +55 -0
- package/dist/llm/router.js +125 -0
- package/dist/llm/router.test.d.ts +1 -0
- package/dist/llm/sse.d.ts +45 -0
- package/dist/llm/sse.js +116 -0
- package/dist/llm/sse.test.d.ts +1 -0
- package/dist/llm/utilityLlm.d.ts +8 -0
- package/dist/llm/utilityLlm.js +33 -0
- package/dist/plugin.adapter.d.ts +2 -0
- package/dist/plugin.adapter.js +27 -0
- package/dist/plugin.adapter.test.d.ts +1 -0
- package/dist/settings/localSettings.d.ts +3 -0
- package/dist/settings/localSettings.js +72 -0
- package/dist/settings/localSettings.test.d.ts +1 -0
- package/dist/settings/roleView.d.ts +11 -0
- package/dist/settings/roleView.js +11 -0
- package/dist/settings/roleView.test.d.ts +1 -0
- package/package.json +39 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { AiError, LanguageModel, Tool } from "@effect/ai";
|
|
2
|
+
import { Effect, Either, FiberRef, JSONSchema, Option, Stream } from "effect";
|
|
3
|
+
import { CurrentModelCallPolicy, CurrentPromptCacheKey } from "@xandreed/core";
|
|
4
|
+
import { finishReasonFromWire, sseStreamParts, usageFromCompletion } from "./sse.js";
|
|
5
|
+
const record = (value) => typeof value === "object" && value !== null ? value : {}, semanticGatewayError = (moduleName, method, body) => Option.flatMap(Option.fromNullable(Either.getOrUndefined(Either.try(() => JSON.parse(body)))), (decoded) => {
|
|
6
|
+
const error = record(record(decoded).error), type = typeof error.type === "string" ? error.type : "", message = typeof error.message === "string" ? error.message : "";
|
|
7
|
+
if (type === "ModelError" || /model .*not supported/i.test(message))
|
|
8
|
+
return Option.some(new AiError.MalformedInput({
|
|
9
|
+
module: moduleName,
|
|
10
|
+
method,
|
|
11
|
+
description: message.length > 0 ? message : "the selected model is not supported"
|
|
12
|
+
}));
|
|
13
|
+
if (type === "CreditsError" || /insufficient (?:balance|credits)|usage limit|quota/i.test(message))
|
|
14
|
+
return Option.some(new AiError.UnknownError({
|
|
15
|
+
module: moduleName,
|
|
16
|
+
method,
|
|
17
|
+
description: `CreditsError: ${message || "provider quota exhausted"}`
|
|
18
|
+
}));
|
|
19
|
+
return Option.none();
|
|
20
|
+
}), aiUnknown = (moduleName, method, e) => new AiError.UnknownError({
|
|
21
|
+
module: moduleName,
|
|
22
|
+
method,
|
|
23
|
+
description: String(e),
|
|
24
|
+
cause: e
|
|
25
|
+
}), requestInfo = (chatUrl) => ({
|
|
26
|
+
method: "POST",
|
|
27
|
+
url: chatUrl,
|
|
28
|
+
urlParams: [],
|
|
29
|
+
hash: Option.none(),
|
|
30
|
+
headers: { "content-type": "application/json" }
|
|
31
|
+
});
|
|
32
|
+
export const toChatMessages = (prompt) => prompt.content.flatMap((message) => {
|
|
33
|
+
if (message.role === "system")
|
|
34
|
+
return [{ role: "system", content: message.content }];
|
|
35
|
+
if (message.role === "user")
|
|
36
|
+
return [{ role: "user", content: message.content.flatMap((p) => p.type === "text" ? [p.text] : []).join("") }];
|
|
37
|
+
if (message.role === "assistant") {
|
|
38
|
+
const text = message.content.flatMap((p) => p.type === "text" ? [p.text] : []).join(""), toolCalls = message.content.flatMap((p) => p.type === "tool-call" ? [
|
|
39
|
+
{
|
|
40
|
+
id: p.id,
|
|
41
|
+
type: "function",
|
|
42
|
+
function: { name: p.name, arguments: JSON.stringify(p.params ?? {}) }
|
|
43
|
+
}
|
|
44
|
+
] : []);
|
|
45
|
+
return [
|
|
46
|
+
{
|
|
47
|
+
role: "assistant",
|
|
48
|
+
content: text.length > 0 ? text : null,
|
|
49
|
+
...toolCalls.length > 0 ? { tool_calls: toolCalls } : {}
|
|
50
|
+
}
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
return message.content.flatMap((p) => p.type === "tool-result" ? [
|
|
54
|
+
{
|
|
55
|
+
role: "tool",
|
|
56
|
+
tool_call_id: p.id,
|
|
57
|
+
content: typeof p.result === "string" ? p.result : JSON.stringify(p.result ?? null)
|
|
58
|
+
}
|
|
59
|
+
] : []);
|
|
60
|
+
}), toChatTools = (tools) => tools.flatMap((tool) => Tool.isUserDefined(tool) ? [
|
|
61
|
+
{
|
|
62
|
+
type: "function",
|
|
63
|
+
function: {
|
|
64
|
+
name: tool.name,
|
|
65
|
+
description: Tool.getDescription(tool),
|
|
66
|
+
parameters: Tool.getJsonSchema(tool)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
] : []);
|
|
70
|
+
const toToolChoice = (choice) => {
|
|
71
|
+
if (choice === "none" || choice === "required")
|
|
72
|
+
return choice;
|
|
73
|
+
if (typeof choice === "object" && choice !== null && "tool" in choice)
|
|
74
|
+
return {
|
|
75
|
+
type: "function",
|
|
76
|
+
function: { name: choice.tool }
|
|
77
|
+
};
|
|
78
|
+
return "auto";
|
|
79
|
+
};
|
|
80
|
+
export const thinkingParams = (model) => {
|
|
81
|
+
if (/kimi-k2[\w.]*-code/i.test(model))
|
|
82
|
+
return { thinking: { type: "enabled" }, reasoning_effort: "high" };
|
|
83
|
+
if (/kimi-k2|deepseek/i.test(model))
|
|
84
|
+
return { thinking: { type: "enabled" } };
|
|
85
|
+
if (/qwen/i.test(model))
|
|
86
|
+
return { enable_thinking: !0 };
|
|
87
|
+
return {};
|
|
88
|
+
}, fromChatCompletion = (moduleName, body) => Effect.gen(function* () {
|
|
89
|
+
const choice = body.choices?.[0];
|
|
90
|
+
if (choice === void 0)
|
|
91
|
+
return yield* Effect.fail(new AiError.MalformedOutput({
|
|
92
|
+
module: moduleName,
|
|
93
|
+
method: "generateText",
|
|
94
|
+
description: "the completion carried no choices"
|
|
95
|
+
}));
|
|
96
|
+
const message = choice.message ?? {}, reasoning = message.reasoning_content ?? message.reasoning, text = message.content, toolCalls = yield* Effect.forEach(message.tool_calls ?? [], (tc) => Effect.try({
|
|
97
|
+
try: () => ({
|
|
98
|
+
type: "tool-call",
|
|
99
|
+
id: tc.id ?? "",
|
|
100
|
+
name: tc.function?.name ?? "",
|
|
101
|
+
params: JSON.parse(tc.function?.arguments ?? "{}")
|
|
102
|
+
}),
|
|
103
|
+
catch: () => new AiError.MalformedOutput({
|
|
104
|
+
module: moduleName,
|
|
105
|
+
method: "generateText",
|
|
106
|
+
description: `tool call ${tc.function?.name ?? "?"} carried unparseable JSON arguments`
|
|
107
|
+
})
|
|
108
|
+
}));
|
|
109
|
+
return [
|
|
110
|
+
...reasoning !== null && reasoning !== void 0 && reasoning.length > 0 ? [{ type: "reasoning", text: reasoning }] : [],
|
|
111
|
+
...text !== null && text !== void 0 && text.length > 0 ? [{ type: "text", text }] : [],
|
|
112
|
+
...toolCalls,
|
|
113
|
+
{
|
|
114
|
+
type: "finish",
|
|
115
|
+
reason: toolCalls.length > 0 ? "tool-calls" : finishReasonFromWire(choice.finish_reason),
|
|
116
|
+
usage: usageFromCompletion(body.usage)
|
|
117
|
+
}
|
|
118
|
+
];
|
|
119
|
+
});
|
|
120
|
+
const chatRequestBody = (config, options, streaming) => Effect.all({ cacheKey: FiberRef.get(CurrentPromptCacheKey), policy: FiberRef.get(CurrentModelCallPolicy) }).pipe(Effect.map(({ cacheKey, policy }) => {
|
|
121
|
+
const tools = toChatTools(options.tools);
|
|
122
|
+
return {
|
|
123
|
+
model: config.model,
|
|
124
|
+
...config.temperature === void 0 ? {} : { temperature: config.temperature },
|
|
125
|
+
messages: toChatMessages(options.prompt),
|
|
126
|
+
stream: streaming,
|
|
127
|
+
...options.responseFormat.type === "json" ? { response_format: {
|
|
128
|
+
type: "json_schema",
|
|
129
|
+
json_schema: { name: options.responseFormat.objectName, strict: !0, schema: JSONSchema.make(options.responseFormat.schema) }
|
|
130
|
+
} } : {},
|
|
131
|
+
...streaming ? { stream_options: { include_usage: !0 } } : {},
|
|
132
|
+
...Option.match(cacheKey, {
|
|
133
|
+
onNone: () => ({}),
|
|
134
|
+
onSome: (key) => ({ prompt_cache_key: key })
|
|
135
|
+
}),
|
|
136
|
+
...config.thinking === void 0 ? thinkingParams(config.model) : { thinking: { type: config.thinking } },
|
|
137
|
+
...config.reasoningEffort === void 0 ? {} : { reasoning: { effort: config.reasoningEffort } },
|
|
138
|
+
...Option.match(policy, {
|
|
139
|
+
onNone: () => ({}),
|
|
140
|
+
onSome: (value) => ({
|
|
141
|
+
...value.maxOutputTokens === void 0 ? {} : { max_tokens: value.maxOutputTokens },
|
|
142
|
+
...config.thinking === "disabled" || config.reasoningEffort !== void 0 ? {} : { reasoning_effort: value.effort }
|
|
143
|
+
})
|
|
144
|
+
}),
|
|
145
|
+
...tools.length > 0 ? { tools, tool_choice: toToolChoice(options.toolChoice) } : {}
|
|
146
|
+
};
|
|
147
|
+
})), postChat = (config, method, body, buffered = !1) => {
|
|
148
|
+
const request = (config.beforeRequest?.(body) ?? Effect.void).pipe(Effect.zipRight(Effect.tryPromise({
|
|
149
|
+
try: async (signal) => {
|
|
150
|
+
const response = await (config.fetchImpl ?? fetch)(config.chatUrl, {
|
|
151
|
+
signal,
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: {
|
|
154
|
+
"content-type": "application/json",
|
|
155
|
+
authorization: `Bearer ${config.apiKey}`
|
|
156
|
+
},
|
|
157
|
+
body: JSON.stringify(body)
|
|
158
|
+
});
|
|
159
|
+
return buffered ? new Response(await response.arrayBuffer(), { status: response.status, statusText: response.statusText, headers: response.headers }) : response;
|
|
160
|
+
},
|
|
161
|
+
catch: (e) => aiUnknown(config.moduleName, method, e)
|
|
162
|
+
})));
|
|
163
|
+
return config.aroundRequest === void 0 ? request : config.aroundRequest(body, request);
|
|
164
|
+
}, failStatus = (config, method, res) => Effect.gen(function* () {
|
|
165
|
+
const text = yield* Effect.tryPromise({
|
|
166
|
+
try: () => res.text(),
|
|
167
|
+
catch: (e) => aiUnknown(config.moduleName, method, e)
|
|
168
|
+
}), semantic = semanticGatewayError(config.moduleName, method, text);
|
|
169
|
+
if (Option.isSome(semantic))
|
|
170
|
+
return yield* Effect.fail(semantic.value);
|
|
171
|
+
const headers = {};
|
|
172
|
+
res.headers.forEach((value, headerName) => {
|
|
173
|
+
headers[headerName] = value;
|
|
174
|
+
});
|
|
175
|
+
return yield* Effect.fail(new AiError.HttpResponseError({
|
|
176
|
+
module: config.moduleName,
|
|
177
|
+
method,
|
|
178
|
+
reason: "StatusCode",
|
|
179
|
+
request: requestInfo(config.chatUrl),
|
|
180
|
+
response: { status: res.status, headers },
|
|
181
|
+
description: text.slice(0, 500)
|
|
182
|
+
}));
|
|
183
|
+
});
|
|
184
|
+
export const makeCompatLanguageModel = (config) => LanguageModel.make({
|
|
185
|
+
generateText: (options) => Effect.gen(function* () {
|
|
186
|
+
const res = yield* postChat(config, "generateText", yield* chatRequestBody(config, options, !1), !0);
|
|
187
|
+
if (!res.ok)
|
|
188
|
+
return yield* failStatus(config, "generateText", res);
|
|
189
|
+
const text = yield* Effect.tryPromise({
|
|
190
|
+
try: () => res.text(),
|
|
191
|
+
catch: (e) => aiUnknown(config.moduleName, "generateText", e)
|
|
192
|
+
}), parsed = yield* Effect.try({
|
|
193
|
+
try: () => JSON.parse(text),
|
|
194
|
+
catch: () => new AiError.MalformedOutput({
|
|
195
|
+
module: config.moduleName,
|
|
196
|
+
method: "generateText",
|
|
197
|
+
description: `the completion body was not JSON: ${text.slice(0, 200)}`
|
|
198
|
+
})
|
|
199
|
+
});
|
|
200
|
+
return yield* fromChatCompletion(config.moduleName, parsed);
|
|
201
|
+
}),
|
|
202
|
+
streamText: (options) => Stream.unwrap(Effect.gen(function* () {
|
|
203
|
+
const res = yield* postChat(config, "streamText", yield* chatRequestBody(config, options, !0));
|
|
204
|
+
if (!res.ok)
|
|
205
|
+
return yield* failStatus(config, "streamText", res);
|
|
206
|
+
const body = res.body;
|
|
207
|
+
if (body === null)
|
|
208
|
+
return yield* Effect.fail(new AiError.MalformedOutput({
|
|
209
|
+
module: config.moduleName,
|
|
210
|
+
method: "streamText",
|
|
211
|
+
description: "the streaming response carried no body"
|
|
212
|
+
}));
|
|
213
|
+
return sseStreamParts({ moduleName: config.moduleName, body });
|
|
214
|
+
}))
|
|
215
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { AuthStore, ModelCatalog } from "@xandreed/core";
|
|
3
|
+
import type { Credential, ModelCatalogEntryType } from "@xandreed/core";
|
|
4
|
+
export declare const configuredModelCatalog: (credentials: ReadonlyMap<string, Credential>) => ReadonlyArray<ModelCatalogEntryType>;
|
|
5
|
+
export type ReasoningEffort = "none" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
6
|
+
/** The reasoning controls accepted by each routed model. `none` on the
|
|
7
|
+
* gpt-5.6 subscription dialect is live-probed (2026-07-16: accepted,
|
|
8
|
+
* reasoning_tokens 0; `minimal` rejected). */
|
|
9
|
+
export declare const reasoningEffortsFor: (selection: string) => ReadonlyArray<ReasoningEffort>;
|
|
10
|
+
export declare const ConfiguredModelCatalogLive: Layer.Layer<ModelCatalog, never, AuthStore>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Effect, Layer } from "effect";
|
|
2
|
+
import { AuthStore, ModelCatalog } from "@xandreed/core";
|
|
3
|
+
const OPENCODE_MODELS = [
|
|
4
|
+
"glm-5.2",
|
|
5
|
+
"glm-5.1",
|
|
6
|
+
"kimi-k2.7-code",
|
|
7
|
+
"kimi-k2.6",
|
|
8
|
+
"mimo-v2.5-pro",
|
|
9
|
+
"mimo-v2.5",
|
|
10
|
+
"qwen3.7-max",
|
|
11
|
+
"qwen3.7-plus",
|
|
12
|
+
"qwen3.6-plus",
|
|
13
|
+
"minimax-m3",
|
|
14
|
+
"minimax-m2.7",
|
|
15
|
+
"deepseek-v4-pro",
|
|
16
|
+
"deepseek-v4-flash"
|
|
17
|
+
], MODELS = {
|
|
18
|
+
opencode: OPENCODE_MODELS,
|
|
19
|
+
"openai-codex": [
|
|
20
|
+
"gpt-5.3-codex-spark",
|
|
21
|
+
"gpt-5.4",
|
|
22
|
+
"gpt-5.4-mini",
|
|
23
|
+
"gpt-5.5",
|
|
24
|
+
"gpt-5.6-luna",
|
|
25
|
+
"gpt-5.6-sol",
|
|
26
|
+
"gpt-5.6-terra"
|
|
27
|
+
],
|
|
28
|
+
openai: [
|
|
29
|
+
"gpt-5",
|
|
30
|
+
"gpt-5.1",
|
|
31
|
+
"gpt-5.2",
|
|
32
|
+
"gpt-5.4",
|
|
33
|
+
"gpt-5.4-mini",
|
|
34
|
+
"gpt-5.4-nano",
|
|
35
|
+
"gpt-5.5",
|
|
36
|
+
"gpt-5.6-luna",
|
|
37
|
+
"gpt-5.6-sol",
|
|
38
|
+
"gpt-5.6-terra"
|
|
39
|
+
],
|
|
40
|
+
anthropic: ["claude-fable-5", "claude-haiku-4-5", "claude-opus-4-8", "claude-sonnet-5"],
|
|
41
|
+
google: ["gemini-3-flash", "gemini-3.1-pro", "gemini-3.5-flash"]
|
|
42
|
+
};
|
|
43
|
+
export const configuredModelCatalog = (credentials) => [...credentials].flatMap(([provider, credential]) => (MODELS[provider] ?? []).map((model) => ({
|
|
44
|
+
selection: `${provider}:${model}`,
|
|
45
|
+
label: provider === "openai-codex" ? `OpenAI subscription \xB7 ${model}` : provider === "openai" ? `OpenAI API key \xB7 ${model}` : void 0,
|
|
46
|
+
provider,
|
|
47
|
+
credential: credential.type
|
|
48
|
+
}))), reasoningEffortsFor = (selection) => {
|
|
49
|
+
if (/^openai-codex:gpt-5\.6-(luna|sol|terra)$/.test(selection))
|
|
50
|
+
return ["none", "low", "medium", "high", "xhigh", "max"];
|
|
51
|
+
if (/^openai-codex:gpt-5/.test(selection))
|
|
52
|
+
return ["low", "medium", "high", "xhigh"];
|
|
53
|
+
if (/^openai:gpt-5/.test(selection))
|
|
54
|
+
return ["low", "medium", "high"];
|
|
55
|
+
return [];
|
|
56
|
+
}, ConfiguredModelCatalogLive = Layer.effect(ModelCatalog, Effect.map(AuthStore, (auth) => ({
|
|
57
|
+
list: auth.all.pipe(Effect.orElseSucceed(() => new Map), Effect.map(configuredModelCatalog))
|
|
58
|
+
})));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { LanguageModel } from "@effect/ai";
|
|
2
|
+
import { HttpClient } from "@effect/platform";
|
|
3
|
+
import { Effect, Redacted, Stream } from "effect";
|
|
4
|
+
export declare const OPENAI_CODEX_API_URL = "https://chatgpt.com/backend-api/codex";
|
|
5
|
+
type Json = Record<string, unknown>;
|
|
6
|
+
/** Translate the standard Responses request into the Codex subscription dialect. */
|
|
7
|
+
export declare const toOpenAiCodexRequestBody: (body: unknown, reasoningEffort?: "none" | "low" | "medium" | "high" | "xhigh" | "max") => Json;
|
|
8
|
+
export declare const mapOpenAiCodexError: (model: string, error: unknown) => unknown;
|
|
9
|
+
export declare const makeOpenAiCodexLanguageModel: (args: {
|
|
10
|
+
readonly model: string;
|
|
11
|
+
readonly accessToken: Redacted.Redacted<string>;
|
|
12
|
+
readonly accountId: string;
|
|
13
|
+
}) => Effect.Effect<{
|
|
14
|
+
streamText: <Options extends import("effect/Types").NoExcessProperties<LanguageModel.GenerateTextOptions<any>, Options>, Tools extends Record<string, import("@effect/ai/Tool").Any> = {}>(options: Options & LanguageModel.GenerateTextOptions<Tools>) => Stream.Stream<import("@effect/ai/Response").StreamPart<Tools>, LanguageModel.ExtractError<Options>, LanguageModel.ExtractContext<Options>>;
|
|
15
|
+
generateText: LanguageModel.Service["generateText"];
|
|
16
|
+
generateObject: <A, I extends Record<string, unknown>, R, Options extends import("effect/Types").NoExcessProperties<LanguageModel.GenerateObjectOptions<any, A, I, R>, Options>, Tools extends Record<string, import("@effect/ai/Tool").Any> = {}>(options: Options & LanguageModel.GenerateObjectOptions<Tools, A, I, R>) => Effect.Effect<LanguageModel.GenerateObjectResponse<Tools, A>, LanguageModel.ExtractError<Options>, R | LanguageModel.ExtractContext<Options>>;
|
|
17
|
+
}, never, import("effect/Scope").Scope | HttpClient.HttpClient>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { AiError, LanguageModel } from "@effect/ai";
|
|
2
|
+
import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai";
|
|
3
|
+
import { HttpClient, HttpClientRequest } from "@effect/platform";
|
|
4
|
+
import { Effect, Either, FiberRef, Option, Redacted, Stream } from "effect";
|
|
5
|
+
import { CurrentModelCallPolicy, CurrentPromptCacheKey, foldStreamParts } from "@xandreed/core";
|
|
6
|
+
import { OpenAiCodexWebSocketHttpClient } from "./openAiCodexWebSocket.js";
|
|
7
|
+
export const OPENAI_CODEX_API_URL = "https://chatgpt.com/backend-api/codex";
|
|
8
|
+
const record = (value) => typeof value === "object" && value !== null ? value : {}, instructionText = (message) => {
|
|
9
|
+
const value = record(message);
|
|
10
|
+
if (value.role !== "developer" && value.role !== "system")
|
|
11
|
+
return [];
|
|
12
|
+
const content = value.content;
|
|
13
|
+
if (typeof content === "string")
|
|
14
|
+
return [content];
|
|
15
|
+
if (!Array.isArray(content))
|
|
16
|
+
return [];
|
|
17
|
+
return content.flatMap((part) => {
|
|
18
|
+
const item = record(part);
|
|
19
|
+
return typeof item.text === "string" ? [item.text] : [];
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
export const toOpenAiCodexRequestBody = (body, reasoningEffort) => {
|
|
23
|
+
const value = record(body), input = Array.isArray(value.input) ? value.input : [], instructions = input.flatMap(instructionText).join(`
|
|
24
|
+
|
|
25
|
+
`), conversationalInput = input.filter((message) => instructionText(message).length === 0), configuredText = record(value.text);
|
|
26
|
+
return {
|
|
27
|
+
...Object.fromEntries(Object.entries(value).filter(([key]) => key !== "max_output_tokens")),
|
|
28
|
+
store: !1,
|
|
29
|
+
stream: !0,
|
|
30
|
+
instructions: instructions.length > 0 ? instructions : "You are a helpful assistant.",
|
|
31
|
+
input: conversationalInput,
|
|
32
|
+
text: { verbosity: configuredText.verbosity ?? "low" },
|
|
33
|
+
include: ["reasoning.encrypted_content"],
|
|
34
|
+
tool_choice: value.tool_choice ?? "auto",
|
|
35
|
+
parallel_tool_calls: value.parallel_tool_calls ?? !0,
|
|
36
|
+
...reasoningEffort === void 0 ? {} : { reasoning: { ...record(value.reasoning), effort: reasoningEffort, summary: "auto" } }
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
const transformRequest = (accountId, reasoningEffort) => (request) => {
|
|
40
|
+
const withHeaders = request.pipe(HttpClientRequest.setHeaders({
|
|
41
|
+
"chatgpt-account-id": accountId,
|
|
42
|
+
originator: "pi",
|
|
43
|
+
"OpenAI-Beta": "responses=experimental",
|
|
44
|
+
accept: "text/event-stream"
|
|
45
|
+
}));
|
|
46
|
+
if (request.body._tag !== "Uint8Array")
|
|
47
|
+
return withHeaders;
|
|
48
|
+
const encodedBody = request.body.body;
|
|
49
|
+
return Option.match(Either.getRight(Either.try(() => JSON.parse(new TextDecoder().decode(encodedBody)))), {
|
|
50
|
+
onNone: () => withHeaders,
|
|
51
|
+
onSome: (decoded) => HttpClientRequest.bodyUnsafeJson(withHeaders, toOpenAiCodexRequestBody(decoded, reasoningEffort))
|
|
52
|
+
});
|
|
53
|
+
}, responseStatus = (error) => {
|
|
54
|
+
const value = record(error), response = record(value.response);
|
|
55
|
+
return typeof response.status === "number" ? response.status : void 0;
|
|
56
|
+
};
|
|
57
|
+
export const mapOpenAiCodexError = (model, error) => responseStatus(error) === 404 ? new AiError.MalformedInput({
|
|
58
|
+
module: "OpenAiCodex",
|
|
59
|
+
method: "responses",
|
|
60
|
+
description: `model ${model} is not available to the configured ChatGPT subscription account`,
|
|
61
|
+
cause: error
|
|
62
|
+
}) : responseStatus(error) === 200 ? new AiError.MalformedOutput({
|
|
63
|
+
module: "OpenAiCodex",
|
|
64
|
+
method: "responses",
|
|
65
|
+
description: `the ${model} subscription stream returned a rejected or incompatible event`,
|
|
66
|
+
cause: error
|
|
67
|
+
}) : error, makeOpenAiCodexLanguageModel = (args) => Effect.all({
|
|
68
|
+
cacheKey: FiberRef.get(CurrentPromptCacheKey),
|
|
69
|
+
policy: FiberRef.get(CurrentModelCallPolicy)
|
|
70
|
+
}).pipe(Effect.flatMap(({ cacheKey, policy }) => OpenAiClient.make({
|
|
71
|
+
apiKey: args.accessToken,
|
|
72
|
+
apiUrl: OPENAI_CODEX_API_URL,
|
|
73
|
+
transformClient: () => OpenAiCodexWebSocketHttpClient.pipe(HttpClient.mapRequest((request) => transformRequest(args.accountId, Option.getOrUndefined(Option.map(policy, (value) => value.effort)))(request.pipe(HttpClientRequest.prependUrl(OPENAI_CODEX_API_URL), HttpClientRequest.bearerToken(args.accessToken)))))
|
|
74
|
+
}).pipe(Effect.flatMap((client) => OpenAiLanguageModel.make({
|
|
75
|
+
model: args.model,
|
|
76
|
+
config: {
|
|
77
|
+
strict: !1,
|
|
78
|
+
store: !1,
|
|
79
|
+
prompt_cache_key: Option.getOrElse(cacheKey, () => "efferent"),
|
|
80
|
+
text: { verbosity: "low" },
|
|
81
|
+
...Option.match(policy, {
|
|
82
|
+
onNone: () => ({}),
|
|
83
|
+
onSome: (value) => ({
|
|
84
|
+
...value.maxOutputTokens === void 0 ? {} : { max_output_tokens: value.maxOutputTokens },
|
|
85
|
+
reasoning: {
|
|
86
|
+
effort: value.effort === "xhigh" || value.effort === "max" ? "high" : value.effort === "none" ? "low" : value.effort,
|
|
87
|
+
summary: "auto"
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
}).pipe(Effect.provideService(OpenAiClient.OpenAiClient, client))), Effect.map((streaming) => {
|
|
93
|
+
const streamText = (options) => streaming.streamText(options).pipe(Stream.mapError((error) => mapOpenAiCodexError(args.model, error)));
|
|
94
|
+
return {
|
|
95
|
+
...streaming,
|
|
96
|
+
streamText,
|
|
97
|
+
generateText: (options) => foldStreamParts(streamText(options), () => Effect.void).pipe(Effect.map((turn) => new LanguageModel.GenerateTextResponse(turn.content)))
|
|
98
|
+
};
|
|
99
|
+
}))));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { HttpClient } from "@effect/platform";
|
|
2
|
+
/** ChatGPT's Luna router currently keys its internal route on Pi's
|
|
3
|
+
* time-ordered UUIDv7 session shape; UUIDv4 reaches a nonexistent rollout
|
|
4
|
+
* alias. Keep one v7 id per Efferent conversation/cache key. */
|
|
5
|
+
export declare const openAiCodexUuidV7: (now?: number) => string;
|
|
6
|
+
/** Codex currently names the WebSocket terminal event `response.done`; the
|
|
7
|
+
* public Responses stream consumed by @effect/ai names it `response.completed`. */
|
|
8
|
+
export declare const normalizeOpenAiCodexWebSocketEvent: (value: unknown) => unknown;
|
|
9
|
+
export declare const isOpenAiCodexControlEvent: (type: unknown) => boolean;
|
|
10
|
+
/** Adapt the ChatGPT Codex WebSocket protocol to an SSE-shaped HttpClient
|
|
11
|
+
* response. OpenAiLanguageModel remains the sole protocol decoder, so text,
|
|
12
|
+
* reasoning, tool calls, tool results, usage, and errors keep their standard
|
|
13
|
+
* Effect representations instead of being reimplemented here. */
|
|
14
|
+
export declare const OpenAiCodexWebSocketHttpClient: HttpClient.HttpClient;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HttpClient,
|
|
3
|
+
HttpClientError,
|
|
4
|
+
HttpClientRequest,
|
|
5
|
+
HttpClientResponse
|
|
6
|
+
} from "@effect/platform";
|
|
7
|
+
import { Effect, Either } from "effect";
|
|
8
|
+
import { release } from "node:os";
|
|
9
|
+
import WebSocket from "ws";
|
|
10
|
+
const WEBSOCKET_BETA = "responses_websockets=2026-02-06", sessionIds = new Map, decodeBody = (request) => {
|
|
11
|
+
if (request.body._tag !== "Uint8Array")
|
|
12
|
+
return {};
|
|
13
|
+
const text = new TextDecoder().decode(request.body.body), parsed = JSON.parse(text);
|
|
14
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
15
|
+
}, websocketUrl = (url) => {
|
|
16
|
+
const next = new URL(url);
|
|
17
|
+
next.protocol = next.protocol === "http:" ? "ws:" : "wss:";
|
|
18
|
+
return next.toString();
|
|
19
|
+
};
|
|
20
|
+
export const openAiCodexUuidV7 = (now = Date.now()) => {
|
|
21
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)), timestamp = BigInt(now);
|
|
22
|
+
bytes[0] = Number(timestamp >> 40n & 0xffn);
|
|
23
|
+
bytes[1] = Number(timestamp >> 32n & 0xffn);
|
|
24
|
+
bytes[2] = Number(timestamp >> 24n & 0xffn);
|
|
25
|
+
bytes[3] = Number(timestamp >> 16n & 0xffn);
|
|
26
|
+
bytes[4] = Number(timestamp >> 8n & 0xffn);
|
|
27
|
+
bytes[5] = Number(timestamp & 0xffn);
|
|
28
|
+
bytes[6] = bytes[6] & 15 | 112;
|
|
29
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
30
|
+
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
31
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
32
|
+
};
|
|
33
|
+
const sessionIdFor = (cacheKey) => {
|
|
34
|
+
const key = typeof cacheKey === "string" && cacheKey.length > 0 ? cacheKey : globalThis.crypto.randomUUID(), existing = sessionIds.get(key);
|
|
35
|
+
if (existing !== void 0)
|
|
36
|
+
return existing;
|
|
37
|
+
const created = openAiCodexUuidV7();
|
|
38
|
+
sessionIds.set(key, created);
|
|
39
|
+
return created;
|
|
40
|
+
}, eventText = (data) => {
|
|
41
|
+
if (typeof data === "string")
|
|
42
|
+
return data;
|
|
43
|
+
if (data instanceof ArrayBuffer)
|
|
44
|
+
return new TextDecoder().decode(new Uint8Array(data));
|
|
45
|
+
if (ArrayBuffer.isView(data))
|
|
46
|
+
return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
|
|
47
|
+
return;
|
|
48
|
+
};
|
|
49
|
+
export const normalizeOpenAiCodexWebSocketEvent = (value) => {
|
|
50
|
+
if (typeof value !== "object" || value === null)
|
|
51
|
+
return value;
|
|
52
|
+
const event = value;
|
|
53
|
+
return event.type === "response.done" ? { ...event, type: "response.completed" } : event;
|
|
54
|
+
}, isOpenAiCodexControlEvent = (type) => typeof type === "string" && (type.startsWith("codex.") || type === "responsesapi.websocket_timing"), OpenAiCodexWebSocketHttpClient = HttpClient.make((request, url, signal) => Effect.tryPromise({
|
|
55
|
+
try: () => new Promise((resolve, reject) => {
|
|
56
|
+
const body = decodeBody(request), safeRequest = request.pipe(HttpClientRequest.setHeader("authorization", "[redacted]"), HttpClientRequest.setHeader("chatgpt-account-id", "[redacted]")), headers = Object.fromEntries(["authorization", "chatgpt-account-id", "originator"].flatMap((key) => {
|
|
57
|
+
const value = request.headers[key];
|
|
58
|
+
return value === void 0 ? [] : [[key, value]];
|
|
59
|
+
}));
|
|
60
|
+
headers["openai-beta"] = WEBSOCKET_BETA;
|
|
61
|
+
headers["user-agent"] = `pi (${process.platform} ${release()}; ${process.arch})`;
|
|
62
|
+
const requestId = sessionIdFor(body.prompt_cache_key);
|
|
63
|
+
body.prompt_cache_key = requestId;
|
|
64
|
+
headers["session-id"] = requestId;
|
|
65
|
+
headers["x-client-request-id"] = requestId;
|
|
66
|
+
const socket = new WebSocket(websocketUrl(url), { headers }), state = { settled: !1 }, stream = new ReadableStream({
|
|
67
|
+
start: (value) => {
|
|
68
|
+
state.controller = value;
|
|
69
|
+
},
|
|
70
|
+
cancel: () => socket.close(1000, "cancelled")
|
|
71
|
+
}), fail = (cause) => {
|
|
72
|
+
if (!state.settled) {
|
|
73
|
+
state.settled = !0;
|
|
74
|
+
reject(cause);
|
|
75
|
+
} else
|
|
76
|
+
state.controller?.error(cause);
|
|
77
|
+
socket.close(1011, "transport error");
|
|
78
|
+
}, abort = () => fail(Error("OpenAI subscription request aborted"));
|
|
79
|
+
signal.addEventListener("abort", abort, { once: !0 });
|
|
80
|
+
socket.addEventListener("open", () => {
|
|
81
|
+
Either.match(Either.try(() => {
|
|
82
|
+
socket.send(JSON.stringify({ type: "response.create", ...body }));
|
|
83
|
+
state.settled = !0;
|
|
84
|
+
return HttpClientResponse.fromWeb(safeRequest, new Response(stream, {
|
|
85
|
+
status: 200,
|
|
86
|
+
headers: { "content-type": "text/event-stream" }
|
|
87
|
+
}));
|
|
88
|
+
}), { onLeft: fail, onRight: resolve });
|
|
89
|
+
});
|
|
90
|
+
socket.addEventListener("message", (event) => {
|
|
91
|
+
Either.match(Either.try(() => {
|
|
92
|
+
const text = eventText(event.data);
|
|
93
|
+
if (text === void 0)
|
|
94
|
+
return;
|
|
95
|
+
const parsed = normalizeOpenAiCodexWebSocketEvent(JSON.parse(text)), type = parsed.type;
|
|
96
|
+
if (isOpenAiCodexControlEvent(type))
|
|
97
|
+
return;
|
|
98
|
+
if (type === "error") {
|
|
99
|
+
fail(Error(JSON.stringify(parsed)));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
state.controller?.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(parsed)}
|
|
103
|
+
|
|
104
|
+
`));
|
|
105
|
+
if (type === "response.completed" || type === "response.incomplete" || type === "response.failed") {
|
|
106
|
+
state.controller?.close();
|
|
107
|
+
signal.removeEventListener("abort", abort);
|
|
108
|
+
socket.close(1000, "done");
|
|
109
|
+
}
|
|
110
|
+
}), { onLeft: fail, onRight: () => {
|
|
111
|
+
return;
|
|
112
|
+
} });
|
|
113
|
+
});
|
|
114
|
+
socket.addEventListener("error", (event) => fail(event.error ?? Error(event.message || "WebSocket handshake failed")));
|
|
115
|
+
socket.addEventListener("close", (event) => {
|
|
116
|
+
if (event.code !== 1000)
|
|
117
|
+
fail(Error(`OpenAI subscription WebSocket closed ${event.code}: ${event.reason}`));
|
|
118
|
+
});
|
|
119
|
+
}),
|
|
120
|
+
catch: (cause) => new HttpClientError.RequestError({
|
|
121
|
+
request: request.pipe(HttpClientRequest.setHeader("authorization", "[redacted]"), HttpClientRequest.setHeader("chatgpt-account-id", "[redacted]")),
|
|
122
|
+
reason: "Transport",
|
|
123
|
+
description: `OpenAI subscription WebSocket failed: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
124
|
+
cause
|
|
125
|
+
})
|
|
126
|
+
}));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { LanguageModel } from "@effect/ai";
|
|
2
|
+
import { HttpClient } from "@effect/platform";
|
|
3
|
+
import { Effect, Redacted } from "effect";
|
|
4
|
+
import type { Scope } from "effect";
|
|
5
|
+
import { AuthError } from "@xandreed/core";
|
|
6
|
+
import type { Credential, ModelSelection } from "@xandreed/core";
|
|
7
|
+
/**
|
|
8
|
+
* Per-provider `LanguageModel.Service` construction. Built PER REQUEST from a
|
|
9
|
+
* freshly-resolved key (never captured at layer build), so a credential or
|
|
10
|
+
* model switch applies on the next call.
|
|
11
|
+
*
|
|
12
|
+
* v1 providers: opencode (OpenAI-compatible gateway — the default), google,
|
|
13
|
+
* anthropic (api key or subscription OAuth), openai (api key). OpenAI-OAuth
|
|
14
|
+
* (Codex) and ollama are deferred until an agent needs them.
|
|
15
|
+
*/
|
|
16
|
+
export declare const OPENCODE_CHAT_URL = "https://opencode.ai/zen/go/v1/chat/completions";
|
|
17
|
+
/** OpenCode serves GPT models through the OpenAI Responses protocol. */
|
|
18
|
+
export declare const OPENCODE_RESPONSES_API_URL = "https://opencode.ai/zen/v1";
|
|
19
|
+
/** Pure protocol routing decision; provider details stay inside this adapter. */
|
|
20
|
+
export declare const usesOpenCodeResponses: (modelId: string) => boolean;
|
|
21
|
+
export interface BuiltProvider {
|
|
22
|
+
readonly svc: LanguageModel.Service;
|
|
23
|
+
/** Anthropic subscription auth requires the Claude Code system block first. */
|
|
24
|
+
readonly prependClaudeCode: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** Prepend the Claude Code system block (subscription-auth requirement). */
|
|
27
|
+
export declare const prependClaudeCode: (options: unknown) => unknown;
|
|
28
|
+
/**
|
|
29
|
+
* Anthropic prompt caching is OPT-IN per request: stamp ephemeral
|
|
30
|
+
* `cache_control` breakpoints on the last system message + the last two
|
|
31
|
+
* non-system messages. Marker placement is not part of Anthropic's content
|
|
32
|
+
* hash, so the markers moving forward each turn never invalidates earlier
|
|
33
|
+
* prefixes. Messages already carrying an explicit `cacheControl` are left
|
|
34
|
+
* alone.
|
|
35
|
+
*/
|
|
36
|
+
export declare const withAnthropicCacheBreakpoints: (options: unknown) => unknown;
|
|
37
|
+
export declare const buildProvider: (selection: ModelSelection, credential: Credential | undefined, key: Redacted.Redacted<string> | undefined) => Effect.Effect<BuiltProvider, AuthError, HttpClient.HttpClient | Scope.Scope>;
|