@sigrun/provider-openai 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +78 -2
- package/dist/chat.d.ts +13 -0
- package/dist/chat.js +42 -0
- package/dist/decisions.d.ts +11 -0
- package/dist/decisions.js +199 -0
- package/dist/embeddings.d.ts +36 -0
- package/dist/embeddings.js +237 -0
- package/dist/host.d.ts +8 -0
- package/dist/host.js +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +26 -0
- package/dist/readiness.d.ts +31 -0
- package/dist/readiness.js +28 -0
- package/dist/settings.d.ts +37 -0
- package/dist/settings.js +138 -0
- package/package.json +52 -5
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this package will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-11-08
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Initial release: OpenAI chat models through pi-ai, scoped settings, and a configurable API base URL.
|
|
13
|
+
- The decision provider kind: one provider per configured model over `POST /v1/decisions`.
|
|
14
|
+
- The embedding provider kind: every configured model over `POST /v1/embeddings`, split by input count and by an estimated token budget, with a per-model `local` report derived from `baseUrl`'s host.
|
|
15
|
+
- One shared readiness tracker across the three kinds.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michael MacLeod
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,79 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sigrun/provider-openai
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Sigrun extension package for OpenAI-compatible providers. Version 0.1.0
|
|
4
|
+
contributes three provider kinds under the provider id `openai`: pi-ai's OpenAI
|
|
5
|
+
chat catalog through the Responses API (classifier models kept out of the chat
|
|
6
|
+
catalog), decision providers over `POST /v1/decisions`, and embedding providers
|
|
7
|
+
over `POST /v1/embeddings`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Add `@sigrun/provider-openai` to the sigrun packages and extensions lists in
|
|
12
|
+
`settings.json`, then restart sigrun.
|
|
13
|
+
|
|
14
|
+
## Credential
|
|
15
|
+
|
|
16
|
+
The provider id is `openai`. Core resolves `auth.json["openai"].apiKey` first,
|
|
17
|
+
then the ambient `OPENAI_API_KEY`; the key is per id, so one credential serves
|
|
18
|
+
every kind. Chat authentication uses pi-ai's normal credential resolution and
|
|
19
|
+
applies the configured base URL to each request. Decisions and embeddings
|
|
20
|
+
receive the resolved key per call; both work without one against a custom
|
|
21
|
+
`baseUrl` that does not need it (a local endpoint, for example).
|
|
22
|
+
|
|
23
|
+
Readiness is reported per kind: `not_configured` when no call has presented a
|
|
24
|
+
credential yet AND `baseUrl` points at `api.openai.com`, otherwise `ready`
|
|
25
|
+
until a call fails (then `degraded`, with the error, until a success). The
|
|
26
|
+
tracker cannot read `auth.json`, so a keyed install pointed at the public API
|
|
27
|
+
reports `not_configured` until its first call — display only, and the first
|
|
28
|
+
successful call clears it.
|
|
29
|
+
|
|
30
|
+
## Settings
|
|
31
|
+
|
|
32
|
+
Scoped settings live in `settings/provider-openai.json`. Every setting is read
|
|
33
|
+
when the extension loads; restart sigrun after editing it.
|
|
34
|
+
|
|
35
|
+
| Setting | Default | Purpose |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| `baseUrl` | `https://api.openai.com/v1` | OpenAI-compatible API root |
|
|
38
|
+
| `chat.enabled` | `true` | Contribute OpenAI chat models |
|
|
39
|
+
| `decisions.enabled` | `true` | Contribute the decision provider |
|
|
40
|
+
| `decisions.models` | `["gpt-6-luna"]` | Decision model ids |
|
|
41
|
+
| `embeddings.enabled` | `true` | Contribute the embedding provider |
|
|
42
|
+
| `embeddings.models` | `[{id: "text-embedding-3-small", dimensions: 1536}, {id: "text-embedding-3-large", dimensions: 3072}]` | Embedding model ids and vector dimensions |
|
|
43
|
+
| `embeddings.maxBatch` | `2048` | Maximum inputs per embedding request |
|
|
44
|
+
|
|
45
|
+
Unknown or malformed settings warn and fall back per key. The default `baseUrl`
|
|
46
|
+
is passed through unchanged; do not add or remove a trailing slash when using
|
|
47
|
+
pi-ai's OpenAI subscription-auth behavior.
|
|
48
|
+
|
|
49
|
+
## Provider kinds
|
|
50
|
+
|
|
51
|
+
- **Chat:** pi-ai's OpenAI Responses provider, model catalog included.
|
|
52
|
+
- **Decisions:** one provider per configured model, posted to
|
|
53
|
+
`POST <baseUrl>/decisions`.
|
|
54
|
+
- **Embeddings:** one provider with every configured model. Each request is
|
|
55
|
+
split so it holds at most `embeddings.maxBatch` inputs and stays within the
|
|
56
|
+
endpoint's per-request token ceiling (estimated at four characters per
|
|
57
|
+
token); vectors return in input order. Each model reports `local` when
|
|
58
|
+
`baseUrl`'s host is loopback or a private address (`127.0.0.0/8`, `::1`,
|
|
59
|
+
`localhost`, `10/8`, `172.16/12`, `192.168/16`, `fc00::/7`), so a local
|
|
60
|
+
embedding server can serve `confidentiality: "local-only"` calls once the
|
|
61
|
+
admin lists the model in core's `embeddings.localModels`.
|
|
62
|
+
|
|
63
|
+
Readiness is one tracker per kind, fed by the calls themselves: `ready` on a
|
|
64
|
+
custom `baseUrl`, and `not_configured` on the default public endpoint until a
|
|
65
|
+
call presents a credential (the tracker cannot read `auth.json`, so this is a
|
|
66
|
+
display-only gap the first successful call closes); a failed call reports
|
|
67
|
+
`degraded` with the error until a success clears it.
|
|
68
|
+
|
|
69
|
+
## Development
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
npm install
|
|
73
|
+
npm test # manifest check + vitest
|
|
74
|
+
npm run typecheck
|
|
75
|
+
npm run build # emits dist/ (committed)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Tests are hermetic and use synthetic credentials and hosts. No real API key or
|
|
79
|
+
network access is required.
|
package/dist/chat.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ProviderContribution } from "@sigrun/sigrun/extension";
|
|
2
|
+
/**
|
|
3
|
+
* pi-ai's OpenAI provider with a chat-only catalog and a per-request base URL.
|
|
4
|
+
*
|
|
5
|
+
* The contribution carries the plugin's shared readiness tracker, fed by
|
|
6
|
+
* the auth resolution pi-ai performs before every request: a resolved
|
|
7
|
+
* apiKey is the plugin's only evidence that a credential exists (it cannot
|
|
8
|
+
* read `auth.json`). Chat requests themselves fail inside pi-ai's stream,
|
|
9
|
+
* which this plugin never observes, so chat readiness reports
|
|
10
|
+
* `not_configured` until the first authenticated request and stays `ready`
|
|
11
|
+
* afterwards — the same display-only limitation every kind shares.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createOpenAIChat(baseUrl: string): ProviderContribution;
|
package/dist/chat.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { isModelType } from "@earendil-works/pi-ai/models";
|
|
2
|
+
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
|
|
3
|
+
import { createOpenAIReadiness } from "./readiness.js";
|
|
4
|
+
/**
|
|
5
|
+
* pi-ai's OpenAI provider with a chat-only catalog and a per-request base URL.
|
|
6
|
+
*
|
|
7
|
+
* The contribution carries the plugin's shared readiness tracker, fed by
|
|
8
|
+
* the auth resolution pi-ai performs before every request: a resolved
|
|
9
|
+
* apiKey is the plugin's only evidence that a credential exists (it cannot
|
|
10
|
+
* read `auth.json`). Chat requests themselves fail inside pi-ai's stream,
|
|
11
|
+
* which this plugin never observes, so chat readiness reports
|
|
12
|
+
* `not_configured` until the first authenticated request and stays `ready`
|
|
13
|
+
* afterwards — the same display-only limitation every kind shares.
|
|
14
|
+
*/
|
|
15
|
+
export function createOpenAIChat(baseUrl) {
|
|
16
|
+
const readiness = createOpenAIReadiness(baseUrl);
|
|
17
|
+
const base = openaiProvider();
|
|
18
|
+
const models = (base.getAllModels?.() ?? base.getModels()).filter((model) => !isModelType(model, "classifier"));
|
|
19
|
+
const provider = {
|
|
20
|
+
...base,
|
|
21
|
+
auth: {
|
|
22
|
+
...base.auth,
|
|
23
|
+
...(base.auth.apiKey === undefined ? {} : {
|
|
24
|
+
apiKey: {
|
|
25
|
+
...base.auth.apiKey,
|
|
26
|
+
resolve: async (input) => {
|
|
27
|
+
const resolved = await base.auth.apiKey.resolve(input);
|
|
28
|
+
if (resolved !== undefined && typeof resolved.auth?.apiKey === "string") {
|
|
29
|
+
readiness.noteCredential(resolved.auth.apiKey);
|
|
30
|
+
}
|
|
31
|
+
return resolved === undefined
|
|
32
|
+
? undefined
|
|
33
|
+
: { ...resolved, auth: { ...resolved.auth, baseUrl } };
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
},
|
|
38
|
+
getModels: () => models.filter((model) => isModelType(model, "chat")),
|
|
39
|
+
getAllModels: () => models,
|
|
40
|
+
};
|
|
41
|
+
return { provider, readiness: () => readiness.readiness() };
|
|
42
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DecisionProviderContribution } from "@sigrun/sigrun/extension";
|
|
2
|
+
type DecisionModels = readonly string[];
|
|
3
|
+
/**
|
|
4
|
+
* The decision-provider contribution. Readiness is `not_configured` when the
|
|
5
|
+
* last call carried no credential and the endpoint is OpenAI's own host (a
|
|
6
|
+
* keyless custom endpoint stays ready); a failed call marks the tracker
|
|
7
|
+
* `degraded` until a success. `evaluate` throws for transport and answer
|
|
8
|
+
* failures; core maps a throw to `failed`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createOpenAIDecisions(baseUrl: string, models: DecisionModels, warn?: (message: string) => void): DecisionProviderContribution;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenAI Decisions API as a sigrun decision provider. pi-ai's own
|
|
3
|
+
* `classify` drops the per-level probabilities of score answers and
|
|
4
|
+
* never throws, so this module builds the request body itself, posts it
|
|
5
|
+
* with pi-ai's transport (`postClassifierRequest` — retries and bearer auth;
|
|
6
|
+
* usage and answers are parsed locally), and parses the raw answers. The predicate-instructions
|
|
7
|
+
* wording mirrors pi-ai's openai-decisions client.
|
|
8
|
+
*/
|
|
9
|
+
import { postClassifierRequest } from "@earendil-works/pi-ai/api/classifier-shared";
|
|
10
|
+
import { isModelType } from "@earendil-works/pi-ai/models";
|
|
11
|
+
import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
|
|
12
|
+
import { createOpenAIReadiness } from "./readiness.js";
|
|
13
|
+
const LABEL = "OpenAI Decisions";
|
|
14
|
+
/** Cloudflare in front of api.openai.com answers 504 on very large inputs; retrying hits it again. */
|
|
15
|
+
const NO_RETRY_STATUSES = [504];
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
function text(value) {
|
|
20
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
21
|
+
}
|
|
22
|
+
/** One classifier catalog entry per configured model, with the settings baseUrl laid over it. */
|
|
23
|
+
function classifierModels(baseUrl, models, warn) {
|
|
24
|
+
const base = openaiProvider();
|
|
25
|
+
const catalog = (base.getAllModels?.() ?? base.getModels()).filter((model) => isModelType(model, "classifier") && model.api === "openai-decisions");
|
|
26
|
+
const byId = new Map(catalog.map((model) => [model.id, model]));
|
|
27
|
+
const usable = [];
|
|
28
|
+
for (const id of models) {
|
|
29
|
+
if (byId.has(id)) {
|
|
30
|
+
usable.push(id);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
warn(`provider-openai: no OpenAI Decisions classifier catalog entry for "${id}"; dropping it`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const ids = usable.length > 0 ? usable : catalog.map((model) => model.id);
|
|
37
|
+
if (usable.length === 0) {
|
|
38
|
+
warn("provider-openai: settings.decisions.models named no usable classifier model; using the default");
|
|
39
|
+
}
|
|
40
|
+
const result = new Map();
|
|
41
|
+
for (const id of ids) {
|
|
42
|
+
// postClassifierRequest posts to an explicit URL built like pi-ai's classify:
|
|
43
|
+
// new URL("decisions", `${baseUrl without trailing slashes}/`).
|
|
44
|
+
result.set(id, { ...byId.get(id), baseUrl });
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
function predicateInstructions(name, question) {
|
|
49
|
+
const instructions = question.instructions === undefined ? name : text(question.instructions);
|
|
50
|
+
const meanings = [
|
|
51
|
+
question.criteria?.true !== undefined && question.criteria.true !== null
|
|
52
|
+
? `True means: ${text(question.criteria.true)}`
|
|
53
|
+
: "",
|
|
54
|
+
question.criteria?.false !== undefined && question.criteria.false !== null
|
|
55
|
+
? `False means: ${text(question.criteria.false)}`
|
|
56
|
+
: "",
|
|
57
|
+
].filter(Boolean);
|
|
58
|
+
return meanings.length > 0 ? `${instructions}\n\n${meanings.join("\n")}` : instructions;
|
|
59
|
+
}
|
|
60
|
+
function wireQuestion(name, question) {
|
|
61
|
+
const instructions = question.instructions === undefined ? name : text(question.instructions);
|
|
62
|
+
if (question.type === "choice") {
|
|
63
|
+
return {
|
|
64
|
+
type: "choice",
|
|
65
|
+
name,
|
|
66
|
+
instructions,
|
|
67
|
+
choices: Object.entries(question.criteria).map(([value, description]) => description === null || description === undefined
|
|
68
|
+
? { value }
|
|
69
|
+
: { value, description: text(description) }),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (question.type === "score") {
|
|
73
|
+
return {
|
|
74
|
+
type: "score",
|
|
75
|
+
name,
|
|
76
|
+
instructions,
|
|
77
|
+
levels: question.criteria.map((label) => ({ label: text(label) })),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return { type: "predicate", name, instructions: predicateInstructions(name, question) };
|
|
81
|
+
}
|
|
82
|
+
function requiredNumber(value, field) {
|
|
83
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
84
|
+
throw new Error(`${LABEL} returned an invalid ${field}`);
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
function probabilities(value, key, id) {
|
|
89
|
+
if (!Array.isArray(value))
|
|
90
|
+
throw new Error(`${LABEL} returned invalid probabilities for ${id}`);
|
|
91
|
+
return Object.fromEntries(value.map((entry) => {
|
|
92
|
+
if (!isRecord(entry) || typeof entry[key] !== "string") {
|
|
93
|
+
throw new Error(`${LABEL} returned invalid probabilities for ${id}`);
|
|
94
|
+
}
|
|
95
|
+
const name = entry[key];
|
|
96
|
+
return [name, requiredNumber(entry.probability, `probability for ${id}.${name}`)];
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
function parseAnswer(id, question, answer) {
|
|
100
|
+
if (answer.type === "refusal")
|
|
101
|
+
throw new Error(`openai refused question ${id}`);
|
|
102
|
+
if (question.type === "choice") {
|
|
103
|
+
if (answer.type !== "choice" || typeof answer.choice !== "string") {
|
|
104
|
+
throw new Error(`${LABEL} did not return a choice answer for ${id}`);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
type: "choice",
|
|
108
|
+
choice: answer.choice,
|
|
109
|
+
confidence: requiredNumber(answer.confidence, `confidence for ${id}`),
|
|
110
|
+
probabilities: probabilities(answer.probabilities, "value", id),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (question.type === "score") {
|
|
114
|
+
if (answer.type !== "score") {
|
|
115
|
+
throw new Error(`${LABEL} did not return a score answer for ${id}`);
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
type: "score",
|
|
119
|
+
score: requiredNumber(answer.score, `score for ${id}`),
|
|
120
|
+
confidence: requiredNumber(answer.confidence, `confidence for ${id}`),
|
|
121
|
+
probabilities: probabilities(answer.probabilities, "label", id),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (answer.type !== "predicate") {
|
|
125
|
+
throw new Error(`${LABEL} did not return a predicate answer for ${id}`);
|
|
126
|
+
}
|
|
127
|
+
return { type: "noul", noul: requiredNumber(answer.probability, `probability for ${id}`) };
|
|
128
|
+
}
|
|
129
|
+
function parseAnswers(body, request, model) {
|
|
130
|
+
if (!isRecord(body) || !Array.isArray(body.answers)) {
|
|
131
|
+
throw new Error(`${LABEL} returned an unexpected response`);
|
|
132
|
+
}
|
|
133
|
+
const byName = new Map();
|
|
134
|
+
for (const answer of body.answers) {
|
|
135
|
+
if (isRecord(answer) && typeof answer.name === "string")
|
|
136
|
+
byName.set(answer.name, answer);
|
|
137
|
+
}
|
|
138
|
+
const answers = {};
|
|
139
|
+
for (const [id, question] of Object.entries(request.questions)) {
|
|
140
|
+
const answer = byName.get(id);
|
|
141
|
+
if (answer === undefined)
|
|
142
|
+
throw new Error(`${LABEL} did not return an answer for ${id}`);
|
|
143
|
+
answers[id] = parseAnswer(id, question, answer);
|
|
144
|
+
}
|
|
145
|
+
const usage = isRecord(body.usage) ? body.usage : undefined;
|
|
146
|
+
return {
|
|
147
|
+
answers,
|
|
148
|
+
model: typeof body.model === "string" ? body.model : model.id,
|
|
149
|
+
usage: usage === undefined
|
|
150
|
+
? undefined
|
|
151
|
+
: {
|
|
152
|
+
input_tokens: typeof usage.input_tokens === "number" ? usage.input_tokens : null,
|
|
153
|
+
output_tokens: typeof usage.output_tokens === "number" ? usage.output_tokens : null,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The decision-provider contribution. Readiness is `not_configured` when the
|
|
159
|
+
* last call carried no credential and the endpoint is OpenAI's own host (a
|
|
160
|
+
* keyless custom endpoint stays ready); a failed call marks the tracker
|
|
161
|
+
* `degraded` until a success. `evaluate` throws for transport and answer
|
|
162
|
+
* failures; core maps a throw to `failed`.
|
|
163
|
+
*/
|
|
164
|
+
export function createOpenAIDecisions(baseUrl, models, warn = () => { }) {
|
|
165
|
+
const catalog = classifierModels(baseUrl, models, warn);
|
|
166
|
+
// The shared three-state tracker (spec §3): every kind reports readiness
|
|
167
|
+
// the same way, including the display-only gap before the first call.
|
|
168
|
+
const readiness = createOpenAIReadiness(baseUrl);
|
|
169
|
+
return {
|
|
170
|
+
id: "openai",
|
|
171
|
+
label: "OpenAI",
|
|
172
|
+
credentialEnv: "OPENAI_API_KEY",
|
|
173
|
+
models: [...catalog.keys()].map((id) => ({ id })),
|
|
174
|
+
readiness: () => readiness.readiness(),
|
|
175
|
+
async evaluate(request, ctx) {
|
|
176
|
+
const model = catalog.get(ctx.model);
|
|
177
|
+
if (model === undefined)
|
|
178
|
+
throw new Error(`${LABEL} has no model "${ctx.model}"`);
|
|
179
|
+
readiness.noteCredential(ctx.credential);
|
|
180
|
+
if (readiness.publicEndpoint && ctx.credential === undefined) {
|
|
181
|
+
throw new Error("OpenAI Decisions: no API key (auth.json openai or OPENAI_API_KEY)");
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const body = await postClassifierRequest(LABEL, new URL("decisions", `${model.baseUrl.replace(/\/+$/u, "")}/`), model, {
|
|
185
|
+
model: model.id,
|
|
186
|
+
input: typeof request.state === "string" ? request.state : JSON.stringify(request.state),
|
|
187
|
+
questions: Object.entries(request.questions).map(([id, question]) => wireQuestion(id, question)),
|
|
188
|
+
}, { apiKey: ctx.credential ?? "", signal: ctx.signal }, NO_RETRY_STATUSES);
|
|
189
|
+
const answers = parseAnswers(body, request, model);
|
|
190
|
+
readiness.noteSuccess();
|
|
191
|
+
return answers;
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
readiness.noteFailure(error instanceof Error ? error.message : String(error));
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenAI Embeddings API as a sigrun embedding provider (spec §3).
|
|
3
|
+
*
|
|
4
|
+
* One provider request becomes one or more `POST <baseUrl>/embeddings`
|
|
5
|
+
* calls: core splits by the model's `maxBatch` (input count), and this
|
|
6
|
+
* module splits again by the endpoint's per-request TOKEN ceiling, which is
|
|
7
|
+
* estimated at four characters per token — the same rough ratio OpenAI
|
|
8
|
+
* documents. Both limits apply to every HTTP request.
|
|
9
|
+
*
|
|
10
|
+
* Vectors come back in input order: the wire carries an `index` per entry
|
|
11
|
+
* and nothing guarantees the array order, so the response is re-sorted by
|
|
12
|
+
* it (a missing/duplicate/out-of-range index is a malformed response, not a
|
|
13
|
+
* guessing game).
|
|
14
|
+
*
|
|
15
|
+
* Failure messages never carry the credential or the input text: a non-2xx
|
|
16
|
+
* body is truncated to a short snippet with both scrubbed out.
|
|
17
|
+
*/
|
|
18
|
+
import type { EmbeddingProviderContribution } from "@sigrun/sigrun/extension";
|
|
19
|
+
import type { OpenAIEmbeddingModelSettings } from "./settings.js";
|
|
20
|
+
/** The endpoint's per-request token ceiling, in ESTIMATED tokens. */
|
|
21
|
+
export declare const EMBEDDING_REQUEST_TOKEN_BUDGET = 300000;
|
|
22
|
+
/**
|
|
23
|
+
* Whether `hostname` is loopback or a private address — the provider's own
|
|
24
|
+
* view of where a model runs (`EmbeddingModelInfo.local`). `hostname` comes
|
|
25
|
+
* from `new URL(baseUrl).hostname`, so an IPv6 literal keeps its brackets.
|
|
26
|
+
* An IPv4-mapped IPv6 literal is judged by the address it maps.
|
|
27
|
+
*/
|
|
28
|
+
export declare function isLocalHost(hostname: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* The embedding-provider contribution. `models` come from the scoped
|
|
31
|
+
* settings; `local` is this provider's own view of the endpoint (a host
|
|
32
|
+
* that is not a loopback or private address is never local, however the
|
|
33
|
+
* admin lists it). `embed` throws for transport, response and configuration
|
|
34
|
+
* failures; core maps a throw to `failed`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createOpenAIEmbeddings(baseUrl: string, models: readonly OpenAIEmbeddingModelSettings[], maxBatch: number): EmbeddingProviderContribution;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { createOpenAIReadiness } from "./readiness.js";
|
|
2
|
+
/**
|
|
3
|
+
* The message prefix for every error this module raises. Deliberately the
|
|
4
|
+
* plugin's own name rather than the vendor's: these strings reach operators
|
|
5
|
+
* through core's failure result and this provider's readiness detail, and a
|
|
6
|
+
* plugin-named prefix is unambiguous there.
|
|
7
|
+
*/
|
|
8
|
+
const PREFIX = "provider-openai embeddings";
|
|
9
|
+
/** The endpoint's per-request token ceiling, in ESTIMATED tokens. */
|
|
10
|
+
export const EMBEDDING_REQUEST_TOKEN_BUDGET = 300_000;
|
|
11
|
+
/**
|
|
12
|
+
* The estimation ratio: four characters per token. An empty string counts as
|
|
13
|
+
* one token (the endpoint bills a minimum), so a batch of empty inputs still
|
|
14
|
+
* advances the budget instead of looking free.
|
|
15
|
+
*/
|
|
16
|
+
const CHARS_PER_TOKEN = 4;
|
|
17
|
+
/** How much of a failing response body reaches the error message. */
|
|
18
|
+
const BODY_SNIPPET_CHARS = 200;
|
|
19
|
+
function isRecord(value) {
|
|
20
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
21
|
+
}
|
|
22
|
+
function octets(address) {
|
|
23
|
+
const parts = address.split(".");
|
|
24
|
+
if (parts.length !== 4)
|
|
25
|
+
return undefined;
|
|
26
|
+
const parsed = parts.map((part) => (/^\d{1,3}$/u.test(part) ? Number(part) : Number.NaN));
|
|
27
|
+
if (parsed.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255))
|
|
28
|
+
return undefined;
|
|
29
|
+
return parsed;
|
|
30
|
+
}
|
|
31
|
+
/** The dotted-quad classes: loopback 127/8, private 10/8, 172.16/12, 192.168/16. */
|
|
32
|
+
function isLocalIPv4(address) {
|
|
33
|
+
const parsed = octets(address);
|
|
34
|
+
if (parsed === undefined)
|
|
35
|
+
return false;
|
|
36
|
+
const first = parsed[0] ?? Number.NaN;
|
|
37
|
+
const second = parsed[1] ?? Number.NaN;
|
|
38
|
+
if (first === 127)
|
|
39
|
+
return true;
|
|
40
|
+
if (first === 10)
|
|
41
|
+
return true;
|
|
42
|
+
if (first === 172 && second >= 16 && second <= 31)
|
|
43
|
+
return true;
|
|
44
|
+
return first === 192 && second === 168;
|
|
45
|
+
}
|
|
46
|
+
/** The IPv6 classes: loopback `::1` and unique-local `fc00::/7`. */
|
|
47
|
+
function isLocalIPv6(address) {
|
|
48
|
+
const lower = address.toLowerCase();
|
|
49
|
+
if (lower === "::1")
|
|
50
|
+
return true;
|
|
51
|
+
const first = lower.split(":")[0] ?? "";
|
|
52
|
+
if (!/^[0-9a-f]{1,4}$/u.test(first))
|
|
53
|
+
return false;
|
|
54
|
+
return (Number.parseInt(first, 16) & 0xfe00) === 0xfc00;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Whether `hostname` is loopback or a private address — the provider's own
|
|
58
|
+
* view of where a model runs (`EmbeddingModelInfo.local`). `hostname` comes
|
|
59
|
+
* from `new URL(baseUrl).hostname`, so an IPv6 literal keeps its brackets.
|
|
60
|
+
* An IPv4-mapped IPv6 literal is judged by the address it maps.
|
|
61
|
+
*/
|
|
62
|
+
export function isLocalHost(hostname) {
|
|
63
|
+
const host = hostname.trim().toLowerCase().replace(/^\[/u, "").replace(/\]$/u, "");
|
|
64
|
+
if (host === "")
|
|
65
|
+
return false;
|
|
66
|
+
if (host === "localhost")
|
|
67
|
+
return true;
|
|
68
|
+
const dotted = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/u.exec(host);
|
|
69
|
+
if (dotted !== null)
|
|
70
|
+
return isLocalIPv4(dotted[1]);
|
|
71
|
+
const mapped = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/u.exec(host);
|
|
72
|
+
if (mapped !== null) {
|
|
73
|
+
const high = Number.parseInt(mapped[1], 16);
|
|
74
|
+
const low = Number.parseInt(mapped[2], 16);
|
|
75
|
+
return isLocalIPv4([high >> 8, high & 0xff, low >> 8, low & 0xff].join("."));
|
|
76
|
+
}
|
|
77
|
+
if (host.includes(":"))
|
|
78
|
+
return isLocalIPv6(host);
|
|
79
|
+
return isLocalIPv4(host);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Whether a thrown value is a caller cancellation rather than a provider
|
|
83
|
+
* failure. Node's `fetch` rejects with a `DOMException` named `AbortError`;
|
|
84
|
+
* the abort flag covers an implementation that rejects some other way after
|
|
85
|
+
* the signal fired. A cancellation says nothing about the endpoint's health,
|
|
86
|
+
* so it must not degrade readiness.
|
|
87
|
+
*/
|
|
88
|
+
function isAbort(error, signal) {
|
|
89
|
+
if (signal.aborted)
|
|
90
|
+
return true;
|
|
91
|
+
return (typeof error === "object" &&
|
|
92
|
+
error !== null &&
|
|
93
|
+
error.name === "AbortError");
|
|
94
|
+
}
|
|
95
|
+
/** The provider's estimated token count for one input text (never below 1). */
|
|
96
|
+
function estimateTokens(text) {
|
|
97
|
+
return Math.max(1, Math.ceil(text.length / CHARS_PER_TOKEN));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Split this provider request's inputs into per-HTTP-request batches: each
|
|
101
|
+
* batch holds at most `maxBatch` inputs AND stays within `budget` estimated
|
|
102
|
+
* tokens. An input that exceeds the budget on its own is sent alone — the
|
|
103
|
+
* estimate is a heuristic and the endpoint, not the estimate, has the final
|
|
104
|
+
* say on a single input.
|
|
105
|
+
*/
|
|
106
|
+
function splitInputs(input, maxBatch, budget) {
|
|
107
|
+
const batches = [];
|
|
108
|
+
let batch = [];
|
|
109
|
+
let tokens = 0;
|
|
110
|
+
for (const text of input) {
|
|
111
|
+
const estimate = estimateTokens(text);
|
|
112
|
+
if (batch.length > 0 && (batch.length >= maxBatch || tokens + estimate > budget)) {
|
|
113
|
+
batches.push(batch);
|
|
114
|
+
batch = [];
|
|
115
|
+
tokens = 0;
|
|
116
|
+
}
|
|
117
|
+
batch.push(text);
|
|
118
|
+
tokens += estimate;
|
|
119
|
+
}
|
|
120
|
+
if (batch.length > 0)
|
|
121
|
+
batches.push(batch);
|
|
122
|
+
return batches;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The error text for a failing response: the status plus a short snippet,
|
|
126
|
+
* with the credential AND the submitted inputs scrubbed out (the message
|
|
127
|
+
* reaches both core's failure result and this provider's readiness detail,
|
|
128
|
+
* and neither may carry either).
|
|
129
|
+
*/
|
|
130
|
+
function failureMessage(status, body, credential, input) {
|
|
131
|
+
let message = body.replace(/\s+/gu, " ").trim();
|
|
132
|
+
for (const sensitive of [credential, ...input]) {
|
|
133
|
+
if (sensitive !== undefined && sensitive !== "")
|
|
134
|
+
message = message.replaceAll(sensitive, "[redacted]");
|
|
135
|
+
}
|
|
136
|
+
const snippet = message.slice(0, BODY_SNIPPET_CHARS);
|
|
137
|
+
return `${PREFIX} returned ${status}${snippet === "" ? "" : `: ${snippet}`}`;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* The embeddings of one response body, in `index` order. A body whose entry
|
|
141
|
+
* count, indexes or vector shape do not match the inputs is malformed.
|
|
142
|
+
*/
|
|
143
|
+
function parseVectors(payload, expected) {
|
|
144
|
+
if (!isRecord(payload) || !Array.isArray(payload.data)) {
|
|
145
|
+
throw new Error(`${PREFIX} returned an unexpected response`);
|
|
146
|
+
}
|
|
147
|
+
if (payload.data.length !== expected) {
|
|
148
|
+
throw new Error(`${PREFIX} returned ${payload.data.length} vectors for ${expected} inputs`);
|
|
149
|
+
}
|
|
150
|
+
const vectors = new Array(expected);
|
|
151
|
+
for (const [position, entry] of payload.data.entries()) {
|
|
152
|
+
if (!isRecord(entry) || !Array.isArray(entry.embedding)) {
|
|
153
|
+
throw new Error(`${PREFIX} returned an entry without an embedding`);
|
|
154
|
+
}
|
|
155
|
+
const index = entry.index === undefined ? position : entry.index;
|
|
156
|
+
if (typeof index !== "number" ||
|
|
157
|
+
!Number.isInteger(index) ||
|
|
158
|
+
index < 0 ||
|
|
159
|
+
index >= expected ||
|
|
160
|
+
vectors[index] !== undefined) {
|
|
161
|
+
throw new Error(`${PREFIX} returned an out-of-range or duplicate index`);
|
|
162
|
+
}
|
|
163
|
+
const vector = entry.embedding;
|
|
164
|
+
if (vector.some((element) => typeof element !== "number")) {
|
|
165
|
+
throw new Error(`${PREFIX} returned a non-numeric embedding element`);
|
|
166
|
+
}
|
|
167
|
+
vectors[index] = vector;
|
|
168
|
+
}
|
|
169
|
+
return vectors;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The embedding-provider contribution. `models` come from the scoped
|
|
173
|
+
* settings; `local` is this provider's own view of the endpoint (a host
|
|
174
|
+
* that is not a loopback or private address is never local, however the
|
|
175
|
+
* admin lists it). `embed` throws for transport, response and configuration
|
|
176
|
+
* failures; core maps a throw to `failed`.
|
|
177
|
+
*/
|
|
178
|
+
export function createOpenAIEmbeddings(baseUrl, models, maxBatch) {
|
|
179
|
+
const readiness = createOpenAIReadiness(baseUrl);
|
|
180
|
+
const local = isLocalHost(new URL(baseUrl).hostname);
|
|
181
|
+
const catalog = models.map((model) => ({
|
|
182
|
+
id: model.id,
|
|
183
|
+
dimensions: model.dimensions,
|
|
184
|
+
maxBatch,
|
|
185
|
+
local,
|
|
186
|
+
}));
|
|
187
|
+
const endpoint = new URL("embeddings", `${baseUrl.replace(/\/+$/u, "")}/`);
|
|
188
|
+
return {
|
|
189
|
+
id: "openai",
|
|
190
|
+
// The Providers-page display name, shared with the other two kinds.
|
|
191
|
+
label: "OpenAI",
|
|
192
|
+
credentialEnv: "OPENAI_API_KEY",
|
|
193
|
+
models: catalog,
|
|
194
|
+
readiness: () => readiness.readiness(),
|
|
195
|
+
async embed(request, ctx) {
|
|
196
|
+
readiness.noteCredential(ctx.credential);
|
|
197
|
+
if (readiness.publicEndpoint && ctx.credential === undefined) {
|
|
198
|
+
// Core only reaches here when readiness said so; keep a direct
|
|
199
|
+
// caller from sending a placeholder key to the public API.
|
|
200
|
+
throw new Error(`${PREFIX}: no API key (auth.json openai or OPENAI_API_KEY)`);
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const vectors = [];
|
|
204
|
+
for (const batch of splitInputs(request.input, maxBatch, EMBEDDING_REQUEST_TOKEN_BUDGET)) {
|
|
205
|
+
const response = await fetch(endpoint, {
|
|
206
|
+
method: "POST",
|
|
207
|
+
headers: {
|
|
208
|
+
"content-type": "application/json",
|
|
209
|
+
...(ctx.credential === undefined ? {} : { authorization: `Bearer ${ctx.credential}` }),
|
|
210
|
+
},
|
|
211
|
+
body: JSON.stringify({ model: request.model, input: batch }),
|
|
212
|
+
signal: ctx.signal,
|
|
213
|
+
});
|
|
214
|
+
if (!response.ok) {
|
|
215
|
+
const body = await response.text().catch(() => "");
|
|
216
|
+
throw new Error(failureMessage(response.status, body, ctx.credential, batch));
|
|
217
|
+
}
|
|
218
|
+
const payload = await response.json().catch(() => undefined);
|
|
219
|
+
if (payload === undefined)
|
|
220
|
+
throw new Error(`${PREFIX} returned an unreadable response`);
|
|
221
|
+
vectors.push(...parseVectors(payload, batch.length));
|
|
222
|
+
}
|
|
223
|
+
readiness.noteSuccess();
|
|
224
|
+
return { vectors };
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
// A caller abort is a cancellation, not a failure: the tracker keeps
|
|
228
|
+
// reporting the provider's real health. Core maps its own deadline
|
|
229
|
+
// abort to `timeout`, which likewise says nothing about the endpoint.
|
|
230
|
+
if (!isAbort(error, ctx.signal)) {
|
|
231
|
+
readiness.noteFailure(error instanceof Error ? error.message : String(error));
|
|
232
|
+
}
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
package/dist/host.d.ts
ADDED
package/dist/host.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** @sigrun/provider-openai — provider-only OpenAI chat, decision and embedding contributions. */
|
|
2
|
+
import type { DecisionProviderContribution, EmbeddingProviderContribution, ProviderContribution } from "@sigrun/sigrun/extension";
|
|
3
|
+
import type { OpenAIProviderHost } from "./host.js";
|
|
4
|
+
export { createOpenAIChat } from "./chat.js";
|
|
5
|
+
export { createOpenAIDecisions } from "./decisions.js";
|
|
6
|
+
export { createOpenAIEmbeddings, EMBEDDING_REQUEST_TOKEN_BUDGET, isLocalHost, } from "./embeddings.js";
|
|
7
|
+
export { createOpenAIReadiness } from "./readiness.js";
|
|
8
|
+
export type { OpenAIReadiness } from "./readiness.js";
|
|
9
|
+
export type { OpenAIProviderHost } from "./host.js";
|
|
10
|
+
export { DEFAULT_DECISION_MODELS, DEFAULT_EMBEDDING_MAX_BATCH, DEFAULT_EMBEDDING_MODELS, DEFAULT_OPENAI_BASE_URL, parseSettings, } from "./settings.js";
|
|
11
|
+
export type { OpenAIEmbeddingModelSettings, OpenAIProviderSettings } from "./settings.js";
|
|
12
|
+
export interface OpenAIProviderResult {
|
|
13
|
+
readonly name: "provider-openai";
|
|
14
|
+
readonly providers: readonly ProviderContribution[];
|
|
15
|
+
readonly decisionProviders?: readonly DecisionProviderContribution[];
|
|
16
|
+
readonly embeddingProviders?: readonly EmbeddingProviderContribution[];
|
|
17
|
+
}
|
|
18
|
+
/** Arity 1: sigrun supplies the scoped host; settings are read once at load. */
|
|
19
|
+
export default function createOpenAIProvider(host: OpenAIProviderHost): Promise<OpenAIProviderResult>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createOpenAIChat } from "./chat.js";
|
|
2
|
+
import { createOpenAIDecisions } from "./decisions.js";
|
|
3
|
+
import { createOpenAIEmbeddings } from "./embeddings.js";
|
|
4
|
+
import { parseSettings } from "./settings.js";
|
|
5
|
+
export { createOpenAIChat } from "./chat.js";
|
|
6
|
+
export { createOpenAIDecisions } from "./decisions.js";
|
|
7
|
+
export { createOpenAIEmbeddings, EMBEDDING_REQUEST_TOKEN_BUDGET, isLocalHost, } from "./embeddings.js";
|
|
8
|
+
export { createOpenAIReadiness } from "./readiness.js";
|
|
9
|
+
export { DEFAULT_DECISION_MODELS, DEFAULT_EMBEDDING_MAX_BATCH, DEFAULT_EMBEDDING_MODELS, DEFAULT_OPENAI_BASE_URL, parseSettings, } from "./settings.js";
|
|
10
|
+
/** Arity 1: sigrun supplies the scoped host; settings are read once at load. */
|
|
11
|
+
export default async function createOpenAIProvider(host) {
|
|
12
|
+
const warn = (message) => console.warn(message);
|
|
13
|
+
const settings = parseSettings(await host.stores.settings.get(), warn);
|
|
14
|
+
return {
|
|
15
|
+
name: "provider-openai",
|
|
16
|
+
providers: settings.chat.enabled ? [createOpenAIChat(settings.baseUrl)] : [],
|
|
17
|
+
...(settings.decisions.enabled
|
|
18
|
+
? { decisionProviders: [createOpenAIDecisions(settings.baseUrl, settings.decisions.models, warn)] }
|
|
19
|
+
: {}),
|
|
20
|
+
...(settings.embeddings.enabled
|
|
21
|
+
? {
|
|
22
|
+
embeddingProviders: [createOpenAIEmbeddings(settings.baseUrl, settings.embeddings.models, settings.embeddings.maxBatch)],
|
|
23
|
+
}
|
|
24
|
+
: {}),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one readiness tracker this plugin's provider kinds share (spec §3):
|
|
3
|
+
* `not_configured` when no credential has been seen AND `baseUrl`'s host is
|
|
4
|
+
* the vendor's own public API, otherwise `ready` until a call fails; a
|
|
5
|
+
* failed call leaves it `degraded` with the error text until a success
|
|
6
|
+
* clears it.
|
|
7
|
+
*
|
|
8
|
+
* A tracker CANNOT read `auth.json` — core owns credentials and hands them
|
|
9
|
+
* over per call — so "a credential exists" is learned only from a call that
|
|
10
|
+
* carried one. The visible consequence is display-only: before the first
|
|
11
|
+
* call, a keyed install pointed at the default base URL reports
|
|
12
|
+
* `not_configured`. That limitation is deliberate and shared by every kind;
|
|
13
|
+
* it is also why the rule keys on the endpoint as well, so a keyless custom
|
|
14
|
+
* endpoint (a local embedding server, say) starts `ready` instead of
|
|
15
|
+
* waiting for a call that may never carry a key.
|
|
16
|
+
*/
|
|
17
|
+
import type { ProviderReadiness } from "@sigrun/sigrun/extension";
|
|
18
|
+
export interface OpenAIReadiness {
|
|
19
|
+
/** True when `baseUrl`'s host is the vendor's public API. */
|
|
20
|
+
readonly publicEndpoint: boolean;
|
|
21
|
+
/** The contribution's `readiness` tracker (a pure synchronous read). */
|
|
22
|
+
readiness(): ProviderReadiness;
|
|
23
|
+
/** Record the credential a call carried (absent/empty teaches nothing). */
|
|
24
|
+
noteCredential(credential: string | undefined): void;
|
|
25
|
+
/** Record a successful call: clears a previous failure. */
|
|
26
|
+
noteSuccess(): void;
|
|
27
|
+
/** Record a failed call; `message` becomes the degraded detail. */
|
|
28
|
+
noteFailure(message: string): void;
|
|
29
|
+
}
|
|
30
|
+
/** One tracker instance per provider kind, over the kind's resolved baseUrl. */
|
|
31
|
+
export declare function createOpenAIReadiness(baseUrl: string): OpenAIReadiness;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** The vendor's public API host: keyless use there is impossible. */
|
|
2
|
+
const PUBLIC_HOST = "api.openai.com";
|
|
3
|
+
/** One tracker instance per provider kind, over the kind's resolved baseUrl. */
|
|
4
|
+
export function createOpenAIReadiness(baseUrl) {
|
|
5
|
+
const publicEndpoint = new URL(baseUrl).hostname === PUBLIC_HOST;
|
|
6
|
+
let configured = !publicEndpoint;
|
|
7
|
+
let failure;
|
|
8
|
+
return {
|
|
9
|
+
publicEndpoint,
|
|
10
|
+
readiness() {
|
|
11
|
+
if (!configured)
|
|
12
|
+
return { status: "not_configured" };
|
|
13
|
+
if (failure !== undefined)
|
|
14
|
+
return { status: "degraded", detail: failure };
|
|
15
|
+
return { status: "ready" };
|
|
16
|
+
},
|
|
17
|
+
noteCredential(credential) {
|
|
18
|
+
if (credential !== undefined && credential !== "")
|
|
19
|
+
configured = true;
|
|
20
|
+
},
|
|
21
|
+
noteSuccess() {
|
|
22
|
+
failure = undefined;
|
|
23
|
+
},
|
|
24
|
+
noteFailure(message) {
|
|
25
|
+
failure = message;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped settings for settings/provider-openai.json. Invalid or unknown keys
|
|
3
|
+
* warn and fall back per key; malformed settings never quarantine the plugin.
|
|
4
|
+
*/
|
|
5
|
+
export declare const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
6
|
+
export declare const DEFAULT_DECISION_MODELS: readonly ["gpt-6-luna"];
|
|
7
|
+
export declare const DEFAULT_EMBEDDING_MODELS: readonly [{
|
|
8
|
+
readonly id: "text-embedding-3-small";
|
|
9
|
+
readonly dimensions: 1536;
|
|
10
|
+
}, {
|
|
11
|
+
readonly id: "text-embedding-3-large";
|
|
12
|
+
readonly dimensions: 3072;
|
|
13
|
+
}];
|
|
14
|
+
export declare const DEFAULT_EMBEDDING_MAX_BATCH = 2048;
|
|
15
|
+
export interface OpenAIEmbeddingModelSettings {
|
|
16
|
+
readonly id: string;
|
|
17
|
+
readonly dimensions: number;
|
|
18
|
+
}
|
|
19
|
+
export interface OpenAIProviderSettings {
|
|
20
|
+
readonly baseUrl: string;
|
|
21
|
+
readonly chat: {
|
|
22
|
+
readonly enabled: boolean;
|
|
23
|
+
};
|
|
24
|
+
readonly decisions: {
|
|
25
|
+
readonly enabled: boolean;
|
|
26
|
+
readonly models: readonly string[];
|
|
27
|
+
};
|
|
28
|
+
readonly embeddings: {
|
|
29
|
+
readonly enabled: boolean;
|
|
30
|
+
readonly models: readonly OpenAIEmbeddingModelSettings[];
|
|
31
|
+
readonly maxBatch: number;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
type Warn = (message: string) => void;
|
|
35
|
+
/** Parse one scoped settings snapshot without reading storage or the environment. */
|
|
36
|
+
export declare function parseSettings(raw: Record<string, unknown>, warn: Warn): OpenAIProviderSettings;
|
|
37
|
+
export {};
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped settings for settings/provider-openai.json. Invalid or unknown keys
|
|
3
|
+
* warn and fall back per key; malformed settings never quarantine the plugin.
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
6
|
+
export const DEFAULT_DECISION_MODELS = ["gpt-6-luna"];
|
|
7
|
+
export const DEFAULT_EMBEDDING_MODELS = [
|
|
8
|
+
{ id: "text-embedding-3-small", dimensions: 1536 },
|
|
9
|
+
{ id: "text-embedding-3-large", dimensions: 3072 },
|
|
10
|
+
];
|
|
11
|
+
export const DEFAULT_EMBEDDING_MAX_BATCH = 2048;
|
|
12
|
+
function section(raw, key, warn) {
|
|
13
|
+
const value = raw[key];
|
|
14
|
+
if (value === undefined)
|
|
15
|
+
return {};
|
|
16
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
17
|
+
warn(`provider-openai: settings.${key} must be an object; using the default`);
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
function unknownKeys(raw, known, path, warn) {
|
|
23
|
+
for (const key of Object.keys(raw)) {
|
|
24
|
+
if (!known.includes(key)) {
|
|
25
|
+
warn(`provider-openai: unknown ${path} key "${key}"; ignoring`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function boolean(raw, key, path, warn) {
|
|
30
|
+
const value = raw[key];
|
|
31
|
+
if (value === undefined)
|
|
32
|
+
return true;
|
|
33
|
+
if (typeof value !== "boolean") {
|
|
34
|
+
warn(`provider-openai: settings.${path}.${key} must be a boolean; using the default`);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
function decisionModels(raw, warn) {
|
|
40
|
+
if (raw.models === undefined)
|
|
41
|
+
return [...DEFAULT_DECISION_MODELS];
|
|
42
|
+
if (!Array.isArray(raw.models)) {
|
|
43
|
+
warn("provider-openai: settings.decisions.models must be an array; using the default");
|
|
44
|
+
return [...DEFAULT_DECISION_MODELS];
|
|
45
|
+
}
|
|
46
|
+
const models = raw.models.filter((model) => typeof model === "string" && model.trim().length > 0).map((model) => model.trim());
|
|
47
|
+
if (models.length !== raw.models.length) {
|
|
48
|
+
warn("provider-openai: settings.decisions.models dropped entries that were not non-empty strings");
|
|
49
|
+
}
|
|
50
|
+
const deduped = [...new Set(models)];
|
|
51
|
+
if (deduped.length !== models.length) {
|
|
52
|
+
warn("provider-openai: settings.decisions.models dropped duplicate entries");
|
|
53
|
+
}
|
|
54
|
+
if (deduped.length === 0) {
|
|
55
|
+
warn("provider-openai: settings.decisions.models left no usable entry; using the default");
|
|
56
|
+
return [...DEFAULT_DECISION_MODELS];
|
|
57
|
+
}
|
|
58
|
+
return deduped;
|
|
59
|
+
}
|
|
60
|
+
function embeddingModels(raw, warn) {
|
|
61
|
+
if (raw.models === undefined)
|
|
62
|
+
return DEFAULT_EMBEDDING_MODELS.map((model) => ({ ...model }));
|
|
63
|
+
if (!Array.isArray(raw.models)) {
|
|
64
|
+
warn("provider-openai: settings.embeddings.models must be an array; using the default");
|
|
65
|
+
return DEFAULT_EMBEDDING_MODELS.map((model) => ({ ...model }));
|
|
66
|
+
}
|
|
67
|
+
const models = [];
|
|
68
|
+
let dropped = false;
|
|
69
|
+
for (const entry of raw.models) {
|
|
70
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
71
|
+
dropped = true;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const value = entry;
|
|
75
|
+
unknownKeys(value, ["id", "dimensions"], "settings.embeddings.models entry", warn);
|
|
76
|
+
if (typeof value.id !== "string" || value.id.trim().length === 0 ||
|
|
77
|
+
typeof value.dimensions !== "number" || !Number.isInteger(value.dimensions) || value.dimensions <= 0) {
|
|
78
|
+
dropped = true;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
models.push({ id: value.id.trim(), dimensions: value.dimensions });
|
|
82
|
+
}
|
|
83
|
+
if (dropped) {
|
|
84
|
+
warn("provider-openai: settings.embeddings.models dropped entries without a non-empty id and positive integer dimensions");
|
|
85
|
+
}
|
|
86
|
+
if (models.length === 0) {
|
|
87
|
+
warn("provider-openai: settings.embeddings.models left no usable entry; using the default");
|
|
88
|
+
return DEFAULT_EMBEDDING_MODELS.map((model) => ({ ...model }));
|
|
89
|
+
}
|
|
90
|
+
return models;
|
|
91
|
+
}
|
|
92
|
+
/** Parse one scoped settings snapshot without reading storage or the environment. */
|
|
93
|
+
export function parseSettings(raw, warn) {
|
|
94
|
+
unknownKeys(raw, ["baseUrl", "chat", "decisions", "embeddings"], "settings", warn);
|
|
95
|
+
let baseUrl = DEFAULT_OPENAI_BASE_URL;
|
|
96
|
+
if (raw.baseUrl !== undefined) {
|
|
97
|
+
if (typeof raw.baseUrl === "string" && raw.baseUrl.length > 0) {
|
|
98
|
+
try {
|
|
99
|
+
new URL(raw.baseUrl);
|
|
100
|
+
baseUrl = raw.baseUrl;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
warn("provider-openai: settings.baseUrl is not a valid URL; using the default");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
warn("provider-openai: settings.baseUrl must be a non-empty string; using the default");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const chat = section(raw, "chat", warn);
|
|
111
|
+
unknownKeys(chat, ["enabled"], "settings.chat", warn);
|
|
112
|
+
const decisions = section(raw, "decisions", warn);
|
|
113
|
+
unknownKeys(decisions, ["enabled", "models"], "settings.decisions", warn);
|
|
114
|
+
const embeddings = section(raw, "embeddings", warn);
|
|
115
|
+
unknownKeys(embeddings, ["enabled", "models", "maxBatch"], "settings.embeddings", warn);
|
|
116
|
+
let maxBatch = DEFAULT_EMBEDDING_MAX_BATCH;
|
|
117
|
+
if (embeddings.maxBatch !== undefined) {
|
|
118
|
+
if (typeof embeddings.maxBatch === "number" && Number.isInteger(embeddings.maxBatch) && embeddings.maxBatch > 0) {
|
|
119
|
+
maxBatch = embeddings.maxBatch;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
warn("provider-openai: settings.embeddings.maxBatch must be a positive integer; using the default");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
baseUrl,
|
|
127
|
+
chat: { enabled: boolean(chat, "enabled", "chat", warn) },
|
|
128
|
+
decisions: {
|
|
129
|
+
enabled: boolean(decisions, "enabled", "decisions", warn),
|
|
130
|
+
models: decisionModels(decisions, warn),
|
|
131
|
+
},
|
|
132
|
+
embeddings: {
|
|
133
|
+
enabled: boolean(embeddings, "enabled", "embeddings", warn),
|
|
134
|
+
models: embeddingModels(embeddings, warn),
|
|
135
|
+
maxBatch,
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@sigrun/provider-openai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sigrun provider plugin: OpenAI chat, decisions, and embeddings",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22.19.0"
|
|
8
|
+
},
|
|
9
|
+
"main": "dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node scripts/check-manifest.mjs --expect-name @sigrun/provider-openai && vitest run",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"build": "tsc -p tsconfig.build.json",
|
|
25
|
+
"prepare": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@earendil-works/pi-ai": "1.5.0",
|
|
30
|
+
"@earendil-works/pi-durable": "1.5.0",
|
|
31
|
+
"@sigrun/sigrun": "file:../sigrun",
|
|
32
|
+
"@types/node": "^26.6.4",
|
|
33
|
+
"typescript": "^7.0.2",
|
|
34
|
+
"vitest": "^5.0.3"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "github",
|
|
39
|
+
"url": "git+https://github.com/sigrun-ai/sigrun-provider-openai.git"
|
|
40
|
+
},
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/sigrun-ai/sigrun-provider-openai/issues"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/sigrun-ai/sigrun-provider-openai#readme",
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"provenance": true
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"@earendil-works/pi-ai": "*",
|
|
51
|
+
"@earendil-works/pi-durable": "*"
|
|
52
|
+
}
|
|
53
|
+
}
|