@xandreed/plugin-models 0.12.0 → 0.14.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.
Files changed (67) hide show
  1. package/README.md +1 -1
  2. package/dist/auth/anthropicOAuth.d.ts +16 -2
  3. package/dist/auth/anthropicOAuth.js +14 -11
  4. package/dist/auth/credential-input.entity.d.ts +34 -0
  5. package/dist/auth/credential-input.entity.functions.d.ts +8 -0
  6. package/dist/auth/credential-input.entity.functions.js +9 -0
  7. package/dist/auth/credential-input.entity.js +20 -0
  8. package/dist/auth/credential-refresh.entity.d.ts +9 -0
  9. package/dist/auth/credential-refresh.entity.functions.d.ts +15 -0
  10. package/dist/auth/credential-refresh.entity.functions.js +12 -0
  11. package/dist/auth/credential-refresh.entity.functions.test.d.ts +1 -0
  12. package/dist/auth/credential-refresh.entity.js +2 -0
  13. package/dist/auth/localAuth.d.ts +2 -1
  14. package/dist/auth/localAuth.js +41 -43
  15. package/dist/auth/memory-auth.adapter.d.ts +27 -0
  16. package/dist/auth/memory-auth.adapter.js +47 -0
  17. package/dist/auth/memory-auth.adapter.test.d.ts +1 -0
  18. package/dist/auth/openAiCodexOAuth.d.ts +3 -2
  19. package/dist/auth/openAiCodexOAuth.js +9 -8
  20. package/dist/index.d.ts +23 -6
  21. package/dist/index.js +28 -1
  22. package/dist/llm/compat.d.ts +39 -8
  23. package/dist/llm/compat.js +80 -29
  24. package/dist/llm/modelCatalog.d.ts +20 -1
  25. package/dist/llm/modelCatalog.js +19 -7
  26. package/dist/llm/openAiCodex.d.ts +4 -1
  27. package/dist/llm/openAiCodex.js +2 -2
  28. package/dist/llm/openAiCodexWebSocket.d.ts +23 -3
  29. package/dist/llm/openAiCodexWebSocket.js +125 -71
  30. package/dist/llm/openAiCodexWebSocket.test.d.ts +1 -0
  31. package/dist/llm/providers.d.ts +18 -4
  32. package/dist/llm/providers.js +49 -24
  33. package/dist/llm/router.d.ts +11 -1
  34. package/dist/llm/router.js +55 -31
  35. package/dist/llm/sse.d.ts +25 -3
  36. package/dist/llm/sse.js +8 -8
  37. package/dist/llm/utilityLlm.d.ts +1 -1
  38. package/dist/llm/utilityLlm.js +4 -4
  39. package/dist/plugin.adapter.d.ts +64 -4
  40. package/dist/plugin.adapter.js +48 -10
  41. package/dist/ports/host-credentials.port.d.ts +21 -0
  42. package/dist/ports/host-credentials.port.js +4 -0
  43. package/dist/ports/model-endpoints.port.d.ts +16 -3
  44. package/dist/ports/model-endpoints.port.js +7 -3
  45. package/dist/ports/models-transport.port.d.ts +46 -0
  46. package/dist/ports/models-transport.port.js +4 -0
  47. package/dist/settings/localSettings.js +6 -46
  48. package/dist/settings/memory-settings.adapter.d.ts +10 -0
  49. package/dist/settings/memory-settings.adapter.js +11 -0
  50. package/dist/settings/memory-settings.adapter.test.d.ts +1 -0
  51. package/dist/settings/settings-value.entity.d.ts +7 -0
  52. package/dist/settings/settings-value.entity.functions.d.ts +29 -0
  53. package/dist/settings/settings-value.entity.functions.js +43 -0
  54. package/dist/settings/settings-value.entity.functions.test.d.ts +1 -0
  55. package/dist/settings/settings-value.entity.js +2 -0
  56. package/dist/transport/http-proxy.entity.d.ts +90 -0
  57. package/dist/transport/http-proxy.entity.functions.d.ts +51 -0
  58. package/dist/transport/http-proxy.entity.functions.js +112 -0
  59. package/dist/transport/http-proxy.entity.functions.test.d.ts +1 -0
  60. package/dist/transport/http-proxy.entity.js +42 -0
  61. package/dist/transport/models-transport.adapter.d.ts +48 -0
  62. package/dist/transport/models-transport.adapter.js +102 -0
  63. package/dist/transport/models-transport.adapter.test.d.ts +1 -0
  64. package/dist/transport/recording-proxy.testing.d.ts +33 -0
  65. package/dist/transport/undici-fetch.adapter.d.ts +13 -0
  66. package/dist/transport/undici-fetch.adapter.js +1 -0
  67. package/package.json +10 -9
@@ -1,7 +1,7 @@
1
- import { AiError, LanguageModel, Tool } from "effect/ai";
2
- import type { Prompt } from "effect/ai";
1
+ import { AiError, LanguageModel, Prompt, Tool } from "effect/ai";
3
2
  import { Effect } from "effect";
4
- import type { CompletionUsage } from "./sse.js";
3
+ import type { CompletionUsage, UsageReading } from "./sse.js";
4
+ import type { ModelsFetch } from "../ports/models-transport.port.js";
5
5
  /**
6
6
  * A generic OpenAI-compatible `/chat/completions` `LanguageModel` over raw
7
7
  * `fetch`. The official `@effect/ai-openai` client targets api.openai.com;
@@ -32,16 +32,47 @@ export interface CompatConfig {
32
32
  * thinking defaults or call policy — for endpoints that reject the
33
33
  * gateway extensions (Gemini's OpenAI-compatible API). */
34
34
  readonly standardOnly?: boolean;
35
- /** Injectable for tests; defaults to global fetch. */
36
- readonly fetchImpl?: typeof fetch;
35
+ /** How the endpoint's usage is read (default: every count, reasoning
36
+ * included; see `UsageReading`). */
37
+ readonly usage?: UsageReading;
38
+ /** The model reads images: send them as `image_url` parts (default false: label lines; see `toChatMessages`). */
39
+ readonly images?: boolean;
40
+ /** The fetch requests go through (the models plugin passes its `ModelsTransport`'s, which knows the proxy); defaults to the global fetch. */
41
+ readonly fetchImpl?: ModelsFetch;
37
42
  /** Host-owned admission (e.g. reserve a worst-case cost) before every call. */
38
43
  readonly beforeRequest?: (body: Readonly<Record<string, unknown>>) => Effect.Effect<void, AiError.AiError>;
39
44
  /** Per-call accounting/resource boundary. Non-streaming responses are fully
40
45
  * buffered inside request so interruption also aborts body consumption. */
41
46
  readonly aroundRequest?: (body: Readonly<Record<string, unknown>>, request: Effect.Effect<Response, AiError.AiError>) => Effect.Effect<Response, AiError.AiError>;
42
47
  }
43
- /** Decoded `Prompt` messages → chat-completions message objects. */
44
- export declare const toChatMessages: (prompt: Prompt.Prompt) => ReadonlyArray<Json>;
48
+ /** How `toChatMessages` writes a request. */
49
+ export interface ChatMessageOptions {
50
+ /** Send images as `image_url` parts (default false: label lines). */
51
+ readonly images?: boolean;
52
+ }
53
+ /**
54
+ * Decoded `Prompt` messages → chat-completions message objects. A
55
+ * text-only user message is its text, as before. Without `images`, a user
56
+ * message is one string: its text, then a label line per file part, and a
57
+ * tool result is its text with a label line per image. With `images`, a
58
+ * user message with images is a content array of its parts in order (an
59
+ * image as an `image_url` part with a `data:` URL), and a tool result is its
60
+ * text in the tool message, while its images, which a tool message cannot
61
+ * carry, follow all the batch's tool messages in one user message, each
62
+ * result's captioned `Images returned by <tool> (call <id>):`; a user
63
+ * message right after the batch is merged into it, so no two user messages
64
+ * follow each other, captioned `The user's message:` when it has images of
65
+ * its own.
66
+ */
67
+ export declare const toChatMessages: (prompt: Prompt.Prompt, options?: ChatMessageOptions) => ReadonlyArray<Json>;
68
+ /**
69
+ * A prompt with every file part a label line: what a route that reads no
70
+ * images is sent (the models plugin rewrites a request so on such routes).
71
+ * A user message with file parts becomes one text part (its text, then a
72
+ * label line each); a tool result with model output becomes its text with a
73
+ * label line per file part, as its result. Messages without files are kept.
74
+ */
75
+ export declare const promptWithImageLabels: (prompt: Prompt.Prompt) => Prompt.Prompt;
45
76
  /** User-defined tools → chat-completions function declarations (empty when
46
77
  * the request carries none — the caller omits the `tools` key entirely). */
47
78
  export declare const toChatTools: (tools: ReadonlyArray<Tool.Any>) => ReadonlyArray<Json>;
@@ -76,6 +107,6 @@ interface ChatCompletion {
76
107
  readonly usage?: CompletionUsage;
77
108
  }
78
109
  /** Parse one non-streaming completion into `@effect/ai` encoded parts. */
79
- export declare const fromChatCompletion: (moduleName: string, body: ChatCompletion) => Effect.Effect<ReadonlyArray<unknown>, AiError.AiError>;
110
+ export declare const fromChatCompletion: (moduleName: string, body: ChatCompletion, reading?: UsageReading) => Effect.Effect<ReadonlyArray<unknown>, AiError.AiError>;
80
111
  export declare const makeCompatLanguageModel: (config: CompatConfig) => Effect.Effect<LanguageModel.LanguageModel>;
81
112
  export {};
@@ -1,7 +1,8 @@
1
- import { AiError, LanguageModel, Tool } from "effect/ai";
2
- import { Effect, Result, Option, Stream } from "effect";
3
- import { CurrentModelCallPolicy, CurrentPromptCacheKey, describeModel, strictJsonSchema, toolParametersSchema } from "@xandreed/core";
1
+ import { AiError, LanguageModel, Prompt, Tool } from "effect/ai";
2
+ import { Effect, Result, Option, Schema, Stream } from "effect";
3
+ import { CurrentModelCallPolicy, CurrentPromptCacheKey, describeModel, ImageMediaType, mediaLabel, strictJsonSchema, toolParametersSchema } from "@xandreed/core";
4
4
  import { finishReasonFromWire, sseStreamParts, usageFromCompletion } from "./sse.js";
5
+ import { redactCredentials } from "../transport/http-proxy.entity.functions.js";
5
6
  const record = (value) => typeof value === "object" && value !== null ? value : {}, semanticGatewayError = (moduleName, method, body) => Option.flatMap(Option.fromNullishOr(Result.getOrUndefined(Result.try(() => JSON.parse(body)))), (decoded) => {
6
7
  const error = record(record(decoded).error), type = typeof error.type === "string" ? error.type : "", message = typeof error.message === "string" ? error.message : "";
7
8
  if (type === "ModelError" || /model .*not supported/i.test(message))
@@ -19,18 +20,57 @@ const record = (value) => typeof value === "object" && value !== null ? value :
19
20
  reason: new AiError.UnknownError({ description: `CreditsError: ${message || "provider quota exhausted"}` })
20
21
  }));
21
22
  return Option.none();
22
- }), aiUnknown = (moduleName, method, e) => AiError.make({ module: moduleName, method, reason: new AiError.UnknownError({ description: String(e) }) }), invalidOutput = (moduleName, method, description) => AiError.make({ module: moduleName, method, reason: new AiError.InvalidOutputError({ description }) }), requestInfo = (chatUrl) => ({
23
+ }), aiUnknown = (moduleName, method, e) => AiError.make({ module: moduleName, method, reason: new AiError.UnknownError({ description: redactCredentials(String(e)) }) }), invalidOutput = (moduleName, method, description) => AiError.make({ module: moduleName, method, reason: new AiError.InvalidOutputError({ description }) }), requestInfo = (chatUrl) => ({
23
24
  method: "POST",
24
25
  url: chatUrl,
25
26
  urlParams: [],
26
27
  headers: { "content-type": "application/json" }
27
- });
28
- export const toChatMessages = (prompt) => prompt.content.flatMap((message) => {
29
- if (message.role === "system")
30
- return [{ role: "system", content: message.content }];
31
- if (message.role === "user")
32
- return [{ role: "user", content: message.content.flatMap((p) => p.type === "text" ? [p.text] : []).join("") }];
33
- if (message.role === "assistant") {
28
+ }), isImageType = Schema.is(ImageMediaType), decodedBytes = (data) => {
29
+ if (typeof data !== "string")
30
+ return data.length;
31
+ const base64 = data.replace(/^data:[^,]*;base64,/i, "").replace(/\s+/g, ""), padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
32
+ return Math.max(0, Math.floor(base64.length * 3 / 4) - padding);
33
+ }, filePartLabel = (part) => {
34
+ const data = part.data;
35
+ return isImageType(part.mediaType) && !(data instanceof URL) ? mediaLabel({ mediaType: part.mediaType, bytes: decodedBytes(data), ...part.fileName === void 0 ? {} : { name: part.fileName } }) : `[file omitted: ${[part.mediaType, ...part.fileName === void 0 ? [] : [part.fileName]].join(", ")}]`;
36
+ }, encodeBase64 = Schema.encodeSync(Schema.Uint8ArrayFromBase64), isSendableImage = (part) => isImageType(part.mediaType), imageUrlPart = (part) => {
37
+ const data = part.data;
38
+ return { type: "image_url", image_url: { url: data instanceof URL ? data.toString() : data instanceof Uint8Array ? `data:${part.mediaType};base64,${encodeBase64(data)}` : /^(data:|https?:\/\/)/i.test(data) ? data : `data:${part.mediaType};base64,${data}` } };
39
+ }, nonEmpty = (line) => line.length > 0, toolResultText = (part, images) => part.modelOutput === void 0 ? typeof part.result === "string" ? part.result : JSON.stringify(part.result ?? null) : part.modelOutput.flatMap((output) => output.type === "text" ? [output.text] : images && isSendableImage(output) ? [] : [filePartLabel(output)]).filter(nonEmpty).join(`
40
+ `), userText = (message) => {
41
+ const text = message.content.flatMap((p) => p.type === "text" ? [p.text] : []).join(""), labels = message.content.flatMap((p) => p.type === "file" ? [filePartLabel(p)] : []);
42
+ return [text, ...labels].filter(nonEmpty).join(`
43
+ `);
44
+ }, contentParts = (content) => typeof content === "string" ? content.length === 0 ? [] : [{ type: "text", text: content }] : Array.isArray(content) ? content : [], USER_CAPTION = "The user's message:", flush = (fold) => fold.pending.length === 0 ? fold.out : [...fold.out, { role: "user", content: fold.pending }];
45
+ export const toChatMessages = (prompt, options = {}) => {
46
+ const images = options.images ?? !1, userMessage = (message) => {
47
+ if (!(images && message.content.some((p) => p.type === "file" && isSendableImage(p))))
48
+ return { role: "user", content: userText(message) };
49
+ return {
50
+ role: "user",
51
+ content: message.content.flatMap((p) => p.type === "text" ? p.text.length === 0 ? [] : [{ type: "text", text: p.text }] : isSendableImage(p) ? [imageUrlPart(p)] : [{ type: "text", text: filePartLabel(p) }])
52
+ };
53
+ }, folded = prompt.content.reduce((fold, message) => {
54
+ if (message.role === "user") {
55
+ const user = userMessage(message);
56
+ if (fold.pending.length === 0)
57
+ return { out: [...fold.out, user], pending: [] };
58
+ const parts = contentParts(user.content), own = parts.some((part) => typeof part === "object" && part !== null && ("type" in part) && part.type === "image_url");
59
+ return { out: [...fold.out, { role: "user", content: [...fold.pending, ...own ? [{ type: "text", text: USER_CAPTION }] : [], ...parts] }], pending: [] };
60
+ }
61
+ if (message.role === "tool") {
62
+ const results = message.content.flatMap((p) => p.type === "tool-result" ? [p] : []), sent = results.flatMap((p) => {
63
+ const parts = images ? (p.modelOutput ?? []).flatMap((output) => output.type === "file" && isSendableImage(output) ? [output] : []) : [];
64
+ return parts.length === 0 ? [] : [{ type: "text", text: `Images returned by ${p.name} (call ${p.id}):` }, ...parts.map(imageUrlPart)];
65
+ }), toolMessages = results.map((p) => {
66
+ const text = toolResultText(p, images), hasImages = images && (p.modelOutput ?? []).some((output) => output.type === "file" && isSendableImage(output));
67
+ return { role: "tool", tool_call_id: p.id, content: text.length === 0 && hasImages ? "[the images this call returned follow]" : text };
68
+ });
69
+ return { out: [...fold.out, ...toolMessages], pending: [...fold.pending, ...sent] };
70
+ }
71
+ const out = flush(fold);
72
+ if (message.role === "system")
73
+ return { out: [...out, { role: "system", content: message.content }], pending: [] };
34
74
  const text = message.content.flatMap((p) => p.type === "text" ? [p.text] : []).join(""), toolCalls = message.content.flatMap((p) => p.type === "tool-call" ? [
35
75
  {
36
76
  id: p.id,
@@ -38,22 +78,33 @@ export const toChatMessages = (prompt) => prompt.content.flatMap((message) => {
38
78
  function: { name: p.name, arguments: JSON.stringify(p.params ?? {}) }
39
79
  }
40
80
  ] : []);
41
- return [
42
- {
81
+ return {
82
+ out: [...out, {
43
83
  role: "assistant",
44
84
  content: text.length > 0 ? text : null,
45
85
  ...toolCalls.length > 0 ? { tool_calls: toolCalls } : {}
46
- }
47
- ];
48
- }
49
- return message.content.flatMap((p) => p.type === "tool-result" ? [
50
- {
51
- role: "tool",
52
- tool_call_id: p.id,
53
- content: typeof p.result === "string" ? p.result : JSON.stringify(p.result ?? null)
54
- }
55
- ] : []);
56
- }), toChatTools = (tools) => tools.flatMap((tool) => Tool.isUserDefined(tool) ? [
86
+ }],
87
+ pending: []
88
+ };
89
+ }, { out: [], pending: [] });
90
+ return flush(folded);
91
+ }, promptWithImageLabels = (prompt) => Prompt.fromMessages(prompt.content.map((message) => {
92
+ if (message.role === "user")
93
+ return message.content.some((p) => p.type === "file") ? Prompt.makeMessage("user", { content: [Prompt.makePart("text", { text: userText(message) })], options: message.options }) : message;
94
+ if (message.role === "tool")
95
+ return message.content.some((p) => p.type === "tool-result" && p.modelOutput !== void 0) ? Prompt.makeMessage("tool", {
96
+ content: message.content.map((p) => p.type !== "tool-result" || p.modelOutput === void 0 ? p : Prompt.makePart("tool-result", {
97
+ id: p.id,
98
+ name: p.name,
99
+ isFailure: p.isFailure,
100
+ result: toolResultText(p, !1),
101
+ providerExecuted: p.providerExecuted,
102
+ options: p.options
103
+ })),
104
+ options: message.options
105
+ }) : message;
106
+ return message;
107
+ })), toChatTools = (tools) => tools.flatMap((tool) => Tool.isUserDefined(tool) ? [
57
108
  {
58
109
  type: "function",
59
110
  function: {
@@ -81,7 +132,7 @@ export const thinkingParams = (model) => {
81
132
  if (/qwen/i.test(model))
82
133
  return { enable_thinking: !0 };
83
134
  return {};
84
- }, fromChatCompletion = (moduleName, body) => Effect.gen(function* () {
135
+ }, fromChatCompletion = (moduleName, body, reading = {}) => Effect.gen(function* () {
85
136
  const choice = body.choices?.[0];
86
137
  if (choice === void 0)
87
138
  return yield* Effect.fail(invalidOutput(moduleName, "generateText", "the completion carried no choices"));
@@ -101,7 +152,7 @@ export const thinkingParams = (model) => {
101
152
  {
102
153
  type: "finish",
103
154
  reason: toolCalls.length > 0 ? "tool-calls" : finishReasonFromWire(choice.finish_reason),
104
- usage: usageFromCompletion(body.usage)
155
+ usage: usageFromCompletion(body.usage, reading)
105
156
  }
106
157
  ];
107
158
  });
@@ -124,7 +175,7 @@ const requestSettings = (config, cacheKey, policy) => ({
124
175
  return {
125
176
  model: config.model,
126
177
  ...settings.sampling,
127
- messages: toChatMessages(options.prompt),
178
+ messages: toChatMessages(options.prompt, { images: config.images ?? !1 }),
128
179
  stream: streaming,
129
180
  ...options.responseFormat.type === "json" ? { response_format: {
130
181
  type: "json_schema",
@@ -184,7 +235,7 @@ export const makeCompatLanguageModel = (config) => LanguageModel.make({
184
235
  try: () => JSON.parse(text),
185
236
  catch: () => invalidOutput(config.moduleName, "generateText", `the completion body was not JSON: ${text.slice(0, 200)}`)
186
237
  });
187
- return yield* fromChatCompletion(config.moduleName, parsed);
238
+ return yield* fromChatCompletion(config.moduleName, parsed, config.usage);
188
239
  }),
189
240
  streamText: (options) => Stream.unwrap(Effect.gen(function* () {
190
241
  const res = yield* postChat(config, "streamText", yield* chatRequestBody(config, options, !0));
@@ -193,7 +244,7 @@ export const makeCompatLanguageModel = (config) => LanguageModel.make({
193
244
  const body = res.body;
194
245
  if (body === null)
195
246
  return yield* Effect.fail(invalidOutput(config.moduleName, "streamText", "the streaming response carried no body"));
196
- return sseStreamParts({ moduleName: config.moduleName, body });
247
+ return sseStreamParts({ moduleName: config.moduleName, body, ...config.usage === void 0 ? {} : { usage: config.usage } });
197
248
  }))
198
249
  }).pipe(Effect.map((model) => describeModel(model, Effect.all({ cacheKey: Effect.service(CurrentPromptCacheKey), policy: Effect.service(CurrentModelCallPolicy) }).pipe(Effect.map(({ cacheKey, policy }) => {
199
250
  const settings = requestSettings(config, cacheKey, policy);
@@ -1,10 +1,29 @@
1
1
  import { Layer } from "effect";
2
2
  import { AuthStore, ModelCatalog } from "@xandreed/core";
3
3
  import type { Credential, ModelCatalogEntryType } from "@xandreed/core";
4
- export declare const configuredModelCatalog: (credentials: ReadonlyMap<string, Credential>) => ReadonlyArray<ModelCatalogEntryType>;
4
+ import type { ModelEndpoints } from "../ports/model-endpoints.port.js";
5
+ /** OpenCode serves its GPT models through the OpenAI Responses protocol, the others through chat completions. */
6
+ export declare const usesOpenCodeResponses: (modelId: string) => boolean;
7
+ /**
8
+ * Whether a built-in route sends a model images: Anthropic's Messages API,
9
+ * the OpenAI Responses routes (`openai`, `openai-codex` but its text-only
10
+ * Spark model, OpenCode's `gpt-*`) and Gemini's chat completions do;
11
+ * OpenCode's other models are text-only until each is verified. A
12
+ * configured endpoint says so itself (`ModelEndpoint.images`). Where it is
13
+ * false, a request's images are sent as label lines.
14
+ */
15
+ export declare const imageInputFor: (selection: string) => boolean;
16
+ /**
17
+ * The models of every provider with a credential. Each entry's `input` is
18
+ * what its route reads, as `buildProvider` routes it: `endpoints` (the
19
+ * models plugin's configured ones; none by default) replace a built-in
20
+ * route of their name.
21
+ */
22
+ export declare const configuredModelCatalog: (credentials: ReadonlyMap<string, Credential>, endpoints?: ModelEndpoints) => ReadonlyArray<ModelCatalogEntryType>;
5
23
  export type ReasoningEffort = "none" | "low" | "medium" | "high" | "xhigh" | "max";
6
24
  /** The reasoning controls accepted by each routed model. `none` on the
7
25
  * gpt-5.6 subscription dialect is live-probed (2026-07-16: accepted,
8
26
  * reasoning_tokens 0; `minimal` rejected). */
9
27
  export declare const reasoningEffortsFor: (selection: string) => ReadonlyArray<ReasoningEffort>;
28
+ /** The catalog over the AuthStore's credentials and the configured endpoints (`CurrentModelEndpoints`). */
10
29
  export declare const ConfiguredModelCatalogLive: Layer.Layer<ModelCatalog, never, AuthStore>;
@@ -1,5 +1,6 @@
1
- import { Effect, Layer } from "effect";
2
- import { AuthStore, ModelCatalog } from "@xandreed/core";
1
+ import { Effect, Layer, Option } from "effect";
2
+ import { AuthStore, ModelCatalog, parseModelSelection } from "@xandreed/core";
3
+ import { CurrentModelEndpoints } from "../ports/model-endpoints.port.js";
3
4
  const OPENCODE_MODELS = [
4
5
  "glm-5.2",
5
6
  "glm-5.1",
@@ -40,11 +41,22 @@ const OPENCODE_MODELS = [
40
41
  anthropic: ["claude-fable-5", "claude-haiku-4-5", "claude-opus-4-8", "claude-sonnet-5"],
41
42
  google: ["gemini-3-flash", "gemini-3.1-pro", "gemini-3.5-flash"]
42
43
  };
43
- export const configuredModelCatalog = (credentials) => [...credentials].flatMap(([provider, credential]) => (MODELS[provider] ?? []).map((model) => ({
44
+ export const usesOpenCodeResponses = (modelId) => /^gpt-/i.test(modelId);
45
+ const TEXT_ONLY_CODEX = new Set(["gpt-5.3-codex-spark"]);
46
+ export const imageInputFor = (selection) => Option.match(parseModelSelection(selection), {
47
+ onNone: () => !1,
48
+ onSome: ({ provider, modelId }) => provider === "anthropic" || provider === "openai" || provider === "google" || provider === "openai-codex" && !TEXT_ONLY_CODEX.has(modelId) || provider === "opencode" && usesOpenCodeResponses(modelId)
49
+ });
50
+ const routeReadsImages = (provider, model, credential, endpoints) => Option.match(Option.fromNullishOr(endpoints[provider]), {
51
+ onSome: (endpoint) => endpoint.images ?? !1,
52
+ onNone: () => !(credential.type === "local" && credential.baseUrl !== void 0) && imageInputFor(`${provider}:${model}`)
53
+ });
54
+ export const configuredModelCatalog = (credentials, endpoints = {}) => [...credentials].flatMap(([provider, credential]) => (MODELS[provider] ?? []).map((model) => ({
44
55
  selection: `${provider}:${model}`,
45
56
  label: provider === "openai-codex" ? `OpenAI subscription \xB7 ${model}` : provider === "openai" ? `OpenAI API key \xB7 ${model}` : void 0,
46
57
  provider,
47
- credential: credential.type
58
+ credential: credential.type,
59
+ input: routeReadsImages(provider, model, credential, endpoints) ? ["text", "image"] : ["text"]
48
60
  }))), reasoningEffortsFor = (selection) => {
49
61
  if (/^openai-codex:gpt-5\.6-(luna|sol|terra)$/.test(selection))
50
62
  return ["none", "low", "medium", "high", "xhigh", "max"];
@@ -53,6 +65,6 @@ export const configuredModelCatalog = (credentials) => [...credentials].flatMap(
53
65
  if (/^openai:gpt-5/.test(selection))
54
66
  return ["low", "medium", "high"];
55
67
  return [];
56
- }, ConfiguredModelCatalogLive = Layer.effect(ModelCatalog, Effect.map(AuthStore, (auth) => ({
57
- list: auth.all.pipe(Effect.orElseSucceed(() => new Map), Effect.map(configuredModelCatalog))
58
- })));
68
+ }, ConfiguredModelCatalogLive = Layer.effect(ModelCatalog, Effect.all({ auth: AuthStore, endpoints: Effect.service(CurrentModelEndpoints) }).pipe(Effect.map(({ auth, endpoints }) => ({
69
+ list: auth.all.pipe(Effect.orElseSucceed(() => new Map), Effect.map((credentials) => configuredModelCatalog(credentials, endpoints)))
70
+ }))));
@@ -1,6 +1,7 @@
1
1
  import { LanguageModel } from "effect/ai";
2
2
  import { HttpClient } from "effect/http";
3
- import { Effect, Redacted } from "effect";
3
+ import { Effect, Option, Redacted } from "effect";
4
+ import type { WebSocketProxy } from "../ports/models-transport.port.js";
4
5
  export declare const OPENAI_CODEX_API_URL = "https://chatgpt.com/backend-api/codex";
5
6
  type Json = Record<string, unknown>;
6
7
  /** Translate the standard Responses request into the Codex subscription dialect. */
@@ -10,5 +11,7 @@ export declare const makeOpenAiCodexLanguageModel: (args: {
10
11
  readonly model: string;
11
12
  readonly accessToken: Redacted.Redacted<string>;
12
13
  readonly accountId: string;
14
+ /** How the WebSocket reaches ChatGPT (`ModelsTransport.webSocketAgent`); default: directly. */
15
+ readonly webSocketProxy?: Option.Option<WebSocketProxy>;
13
16
  }) => Effect.Effect<LanguageModel.LanguageModel, never, HttpClient.HttpClient>;
14
17
  export {};
@@ -3,7 +3,7 @@ import { OpenAiClient, OpenAiLanguageModel } from "@effect/ai-openai";
3
3
  import { HttpClient, HttpClientRequest } from "effect/http";
4
4
  import { Effect, Result, Option, Redacted, Stream } from "effect";
5
5
  import { CurrentModelCallPolicy, CurrentPromptCacheKey, describeModel, foldStreamParts } from "@xandreed/core";
6
- import { OpenAiCodexWebSocketHttpClient } from "./openAiCodexWebSocket.js";
6
+ import { makeOpenAiCodexWebSocketHttpClient } from "./openAiCodexWebSocket.js";
7
7
  export const OPENAI_CODEX_API_URL = "https://chatgpt.com/backend-api/codex";
8
8
  const record = (value) => typeof value === "object" && value !== null ? value : {}, instructionText = (message) => {
9
9
  const value = record(message);
@@ -69,7 +69,7 @@ export const mapOpenAiCodexError = (model, error) => responseStatus(error) === 4
69
69
  }).pipe(Effect.flatMap(({ cacheKey, policy }) => OpenAiClient.make({
70
70
  apiKey: args.accessToken,
71
71
  apiUrl: OPENAI_CODEX_API_URL,
72
- 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)))))
72
+ transformClient: () => makeOpenAiCodexWebSocketHttpClient(args.webSocketProxy ?? Option.none()).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)))))
73
73
  }).pipe(Effect.flatMap((client) => OpenAiLanguageModel.make({
74
74
  model: args.model,
75
75
  config: {
@@ -1,4 +1,7 @@
1
- import { HttpClient } from "effect/http";
1
+ import { HttpClient, HttpClientError } from "effect/http";
2
+ import { Option } from "effect";
3
+ import type { WebSocketProxy } from "../ports/models-transport.port.js";
4
+ type Json = Record<string, unknown>;
2
5
  /** ChatGPT's Luna router currently keys its internal route on Pi's
3
6
  * time-ordered UUIDv7 session shape; UUIDv4 reaches a nonexistent rollout
4
7
  * alias. Keep one v7 id per Efferent conversation/cache key. */
@@ -6,9 +9,26 @@ export declare const openAiCodexUuidV7: (now?: number) => string;
6
9
  /** Codex currently names the WebSocket terminal event `response.done`; the
7
10
  * public Responses stream consumed by @effect/ai names it `response.completed`. */
8
11
  export declare const normalizeOpenAiCodexWebSocketEvent: (value: unknown) => unknown;
12
+ /**
13
+ * The OpenAI error body of an `error` frame that refuses the request, which
14
+ * the HTTP Responses endpoint would answer with a 400: the frame's status is
15
+ * 400, its error's type or code is `invalid_request_error`, or its message
16
+ * names an image. The error is the frame's `error` object, or the frame
17
+ * itself (the Responses stream's own `error` event). Any other frame: none.
18
+ */
19
+ export declare const openAiCodexRefusal: (event: unknown) => Option.Option<Json>;
9
20
  export declare const isOpenAiCodexControlEvent: (type: unknown) => boolean;
10
21
  /** Adapt the ChatGPT Codex WebSocket protocol to an SSE-shaped HttpClient
11
22
  * response. OpenAiLanguageModel remains the sole protocol decoder, so text,
12
23
  * 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;
24
+ * Effect representations instead of being reimplemented here. `proxy` is how
25
+ * the socket reaches ChatGPT (`ModelsTransport.webSocketAgent`); `None`
26
+ * connects directly. A route this runtime cannot use fails every request.
27
+ * The response is the stream once the first event arrives, unless that
28
+ * event is an `error` refusing the request (`openAiCodexRefusal`): the
29
+ * request then fails with a 400 status and the OpenAI error body, as the
30
+ * HTTP endpoint answers it, so a refused image is an `InvalidRequestError`. */
31
+ export declare const makeOpenAiCodexWebSocketHttpClient: (proxy: Option.Option<WebSocketProxy>) => HttpClient.HttpClient.With<HttpClientError.HttpClientError, never>;
32
+ /** The subscription client connecting directly, with no proxy. */
33
+ export declare const OpenAiCodexWebSocketHttpClient: HttpClient.HttpClient.With<HttpClientError.HttpClientError, never>;
34
+ export {};
@@ -1,7 +1,9 @@
1
1
  import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
2
- import { Effect, Result } from "effect";
2
+ import { Effect, Match, Option, Redacted, Result } from "effect";
3
3
  import { release } from "node:os";
4
4
  import WebSocket from "ws";
5
+ import { redactCredentials } from "../transport/http-proxy.entity.functions.js";
6
+ import { currentRuntime } from "../transport/models-transport.adapter.js";
5
7
  const WEBSOCKET_BETA = "responses_websockets=2026-02-06", sessionIds = new Map, decodeBody = (request) => {
6
8
  if (request.body._tag !== "Uint8Array")
7
9
  return {};
@@ -46,77 +48,129 @@ export const normalizeOpenAiCodexWebSocketEvent = (value) => {
46
48
  return value;
47
49
  const event = value;
48
50
  return event.type === "response.done" ? { ...event, type: "response.completed" } : event;
49
- }, isOpenAiCodexControlEvent = (type) => typeof type === "string" && (type.startsWith("codex.") || type === "responsesapi.websocket_timing"), OpenAiCodexWebSocketHttpClient = HttpClient.make((request, url, signal) => Effect.tryPromise({
50
- try: () => new Promise((resolve, reject) => {
51
- 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) => {
52
- const value = request.headers[key];
53
- return value === void 0 ? [] : [[key, value]];
54
- }));
55
- headers["openai-beta"] = WEBSOCKET_BETA;
56
- headers["user-agent"] = `pi (${process.platform} ${release()}; ${process.arch})`;
57
- const requestId = sessionIdFor(body.prompt_cache_key);
58
- body.prompt_cache_key = requestId;
59
- headers["session-id"] = requestId;
60
- headers["x-client-request-id"] = requestId;
61
- const socket = new WebSocket(websocketUrl(url), { headers }), state = { settled: !1 }, stream = new ReadableStream({
62
- start: (value) => {
63
- state.controller = value;
64
- },
65
- cancel: () => socket.close(1000, "cancelled")
66
- }), fail = (cause) => {
67
- if (!state.settled) {
68
- state.settled = !0;
69
- reject(cause);
70
- } else
71
- state.controller?.error(cause);
72
- socket.close(1011, "transport error");
73
- }, abort = () => fail(Error("OpenAI subscription request aborted"));
74
- signal.addEventListener("abort", abort, { once: !0 });
75
- socket.addEventListener("open", () => {
76
- Result.match(Result.try(() => {
77
- socket.send(JSON.stringify({ type: "response.create", ...body }));
78
- state.settled = !0;
79
- return HttpClientResponse.fromWeb(safeRequest, new Response(stream, {
80
- status: 200,
81
- headers: { "content-type": "text/event-stream" }
82
- }));
83
- }), { onFailure: fail, onSuccess: resolve });
84
- });
85
- socket.addEventListener("message", (event) => {
86
- Result.match(Result.try(() => {
87
- const text = eventText(event.data);
88
- if (text === void 0)
89
- return;
90
- const parsed = normalizeOpenAiCodexWebSocketEvent(JSON.parse(text)), type = parsed.type;
91
- if (isOpenAiCodexControlEvent(type))
92
- return;
93
- if (type === "error") {
94
- fail(Error(JSON.stringify(parsed)));
95
- return;
51
+ };
52
+ const recordOf = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? Option.some(value) : Option.none(), stringOf = (value) => typeof value === "string" ? Option.some(value) : Option.none();
53
+ export const openAiCodexRefusal = (event) => Option.flatMap(Option.filter(recordOf(event), (frame) => frame.type === "error"), (frame) => {
54
+ const error = Option.getOrElse(recordOf(frame.error), () => frame), message = Option.getOrElse(stringOf(error.message), () => ""), type = stringOf(error.type), code = stringOf(error.code);
55
+ return frame.status === 400 || /image/i.test(message) || Option.contains(type, "invalid_request_error") || Option.contains(code, "invalid_request_error") ? Option.some({ error: {
56
+ message: message.length > 0 ? message : "the request was refused",
57
+ type: Option.getOrElse(Option.filter(type, (value) => value !== "error"), () => "invalid_request_error"),
58
+ code: Option.getOrNull(code),
59
+ param: Option.getOrNull(stringOf(error.param))
60
+ } }) : Option.none();
61
+ }), isOpenAiCodexControlEvent = (type) => typeof type === "string" && (type.startsWith("codex.") || type === "responsesapi.websocket_timing");
62
+ const wsSocket = (socket) => ({
63
+ send: (data) => socket.send(data),
64
+ close: (code, reason) => socket.close(code, reason),
65
+ onOpen: (listener) => socket.addEventListener("open", () => listener()),
66
+ onMessage: (listener) => socket.addEventListener("message", (event) => listener(event.data)),
67
+ onError: (listener) => socket.addEventListener("error", (event) => listener(event.error instanceof Error ? event.error : Error(event.message || "WebSocket handshake failed"))),
68
+ onClose: (listener) => socket.addEventListener("close", (event) => listener(event.code, event.reason))
69
+ }), bunSocket = (url, options) => {
70
+ const socket = Reflect.construct(globalThis.WebSocket, [url, options]);
71
+ return {
72
+ send: (data) => socket.send(data),
73
+ close: (code, reason) => socket.close(code, reason),
74
+ onOpen: (listener) => socket.addEventListener("open", () => listener()),
75
+ onMessage: (listener) => socket.addEventListener("message", (event) => listener(event.data)),
76
+ onError: (listener) => socket.addEventListener("error", (event) => listener(Error("message" in event && typeof event.message === "string" && event.message.length > 0 ? event.message : "WebSocket handshake failed"))),
77
+ onClose: (listener) => socket.addEventListener("close", (event) => listener(event.code, event.reason))
78
+ };
79
+ }, openSocket = (url, headers, proxy) => Option.match(proxy, {
80
+ onNone: () => wsSocket(new WebSocket(url, { headers })),
81
+ onSome: (route) => Match.value(route).pipe(Match.tag("Agent", ({ agent }) => wsSocket(new WebSocket(url, { headers, agent }))), Match.tag("Proxy", ({ url: proxyUrl }) => bunSocket(url, { headers, proxy: Redacted.value(proxyUrl) })), Match.exhaustive)
82
+ }), routeMismatch = (route) => {
83
+ const runtime = currentRuntime();
84
+ return Match.value(route).pipe(Match.tag("Agent", () => runtime._tag === "Node" ? Option.none() : Option.some(`the WebSocket route is a Node agent, which ${runtime._tag === "Bun" ? "Bun's WebSocket" : "this runtime's WebSocket"} cannot use`)), Match.tag("Proxy", () => runtime._tag === "Bun" ? Option.none() : Option.some("the WebSocket route is a proxy URL for Bun's WebSocket, which this runtime's WebSocket would ignore")), Match.exhaustive, Option.map((why) => `${why}: refused rather than connecting directly past the proxy`));
85
+ }, transportFailure = (request, cause) => new HttpClientError.HttpClientError({
86
+ reason: new HttpClientError.TransportError({
87
+ request: request.pipe(HttpClientRequest.setHeader("authorization", "[redacted]"), HttpClientRequest.setHeader("chatgpt-account-id", "[redacted]")),
88
+ description: `OpenAI subscription WebSocket failed: ${redactCredentials(cause instanceof Error ? cause.message : String(cause))}`,
89
+ cause
90
+ })
91
+ });
92
+ export const makeOpenAiCodexWebSocketHttpClient = (proxy) => HttpClient.make((request, url, signal) => Option.match(Option.flatMap(proxy, routeMismatch), {
93
+ onSome: (why) => Effect.fail(transportFailure(request, Error(why))),
94
+ onNone: () => Effect.tryPromise({
95
+ try: () => new Promise((resolve, reject) => {
96
+ 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) => {
97
+ const value = request.headers[key];
98
+ return value === void 0 ? [] : [[key, value]];
99
+ }));
100
+ headers["openai-beta"] = WEBSOCKET_BETA;
101
+ headers["user-agent"] = `pi (${process.platform} ${release()}; ${process.arch})`;
102
+ const requestId = sessionIdFor(body.prompt_cache_key);
103
+ body.prompt_cache_key = requestId;
104
+ headers["session-id"] = requestId;
105
+ headers["x-client-request-id"] = requestId;
106
+ const socket = openSocket(websocketUrl(url), headers, proxy), state = { phase: "connecting" }, stream = new ReadableStream({
107
+ start: (value) => {
108
+ state.controller = value;
109
+ },
110
+ cancel: () => socket.close(1000, "cancelled")
111
+ }), respond = (response) => {
112
+ state.phase = "settled";
113
+ resolve(HttpClientResponse.fromWeb(safeRequest, response));
114
+ }, streaming = () => {
115
+ if (state.phase === "sent")
116
+ respond(new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } }));
117
+ }, fail = (cause) => {
118
+ if (state.phase === "connecting") {
119
+ state.phase = "settled";
120
+ reject(cause);
121
+ } else {
122
+ streaming();
123
+ state.controller?.error(cause);
96
124
  }
97
- state.controller?.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(parsed)}
125
+ socket.close(1011, "transport error");
126
+ }, abort = () => fail(Error("OpenAI subscription request aborted"));
127
+ signal.addEventListener("abort", abort, { once: !0 });
128
+ socket.onOpen(() => {
129
+ Result.match(Result.try(() => {
130
+ socket.send(JSON.stringify({ type: "response.create", ...body }));
131
+ state.phase = "sent";
132
+ }), { onFailure: fail, onSuccess: () => {
133
+ return;
134
+ } });
135
+ });
136
+ socket.onMessage((data) => {
137
+ Result.match(Result.try(() => {
138
+ const text = eventText(data);
139
+ if (text === void 0)
140
+ return;
141
+ const parsed = normalizeOpenAiCodexWebSocketEvent(JSON.parse(text)), type = parsed.type;
142
+ if (isOpenAiCodexControlEvent(type))
143
+ return;
144
+ const refused = state.phase === "sent" ? openAiCodexRefusal(parsed) : Option.none();
145
+ if (Option.isSome(refused)) {
146
+ respond(new Response(JSON.stringify(refused.value), { status: 400, headers: { "content-type": "application/json" } }));
147
+ signal.removeEventListener("abort", abort);
148
+ socket.close(1000, "refused");
149
+ return;
150
+ }
151
+ streaming();
152
+ if (type === "error") {
153
+ fail(Error(JSON.stringify(parsed)));
154
+ return;
155
+ }
156
+ state.controller?.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(parsed)}
98
157
 
99
158
  `));
100
- if (type === "response.completed" || type === "response.incomplete" || type === "response.failed") {
101
- state.controller?.close();
102
- signal.removeEventListener("abort", abort);
103
- socket.close(1000, "done");
104
- }
105
- }), { onFailure: fail, onSuccess: () => {
106
- return;
107
- } });
108
- });
109
- socket.addEventListener("error", (event) => fail(event.error ?? Error(event.message || "WebSocket handshake failed")));
110
- socket.addEventListener("close", (event) => {
111
- if (event.code !== 1000)
112
- fail(Error(`OpenAI subscription WebSocket closed ${event.code}: ${event.reason}`));
113
- });
114
- }),
115
- catch: (cause) => new HttpClientError.HttpClientError({
116
- reason: new HttpClientError.TransportError({
117
- request: request.pipe(HttpClientRequest.setHeader("authorization", "[redacted]"), HttpClientRequest.setHeader("chatgpt-account-id", "[redacted]")),
118
- description: `OpenAI subscription WebSocket failed: ${cause instanceof Error ? cause.message : String(cause)}`,
119
- cause
120
- })
159
+ if (type === "response.completed" || type === "response.incomplete" || type === "response.failed") {
160
+ state.controller?.close();
161
+ signal.removeEventListener("abort", abort);
162
+ socket.close(1000, "done");
163
+ }
164
+ }), { onFailure: fail, onSuccess: () => {
165
+ return;
166
+ } });
167
+ });
168
+ socket.onError(fail);
169
+ socket.onClose((code, reason) => {
170
+ if (code !== 1000)
171
+ fail(Error(`OpenAI subscription WebSocket closed ${code}: ${reason}`));
172
+ });
173
+ }),
174
+ catch: (cause) => transportFailure(request, cause)
121
175
  })
122
- }));
176
+ })).pipe(HttpClient.filterStatusOk), OpenAiCodexWebSocketHttpClient = makeOpenAiCodexWebSocketHttpClient(Option.none());
@@ -0,0 +1 @@
1
+ export {};