@sigrun/provider-bifrost 0.0.0-stage → 0.1.1
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 +65 -0
- package/LICENSE +21 -0
- package/README.md +151 -2
- package/dist/catalog.d.ts +236 -0
- package/dist/catalog.js +448 -0
- package/dist/config.d.ts +77 -0
- package/dist/config.js +161 -0
- package/dist/http.d.ts +17 -0
- package/dist/http.js +88 -0
- package/dist/index.d.ts +146 -0
- package/dist/index.js +280 -0
- package/package.json +52 -5
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport only (ported from pi-bifrost-gateway 0.3.0 @ 5d15a28, module
|
|
3
|
+
* unchanged except the User-Agent name): GET with Bearer auth + typebox
|
|
4
|
+
* validation, a hard 15 s deadline so a silent peer can never hang the
|
|
5
|
+
* refresh.
|
|
6
|
+
*/
|
|
7
|
+
import type { TProperties, TSchema } from "typebox";
|
|
8
|
+
import type { Validator } from "typebox/compile";
|
|
9
|
+
export declare class HttpError extends Error {
|
|
10
|
+
readonly status: number;
|
|
11
|
+
readonly code: string;
|
|
12
|
+
constructor(status: number, code: string, message: string);
|
|
13
|
+
}
|
|
14
|
+
export interface FetchJsonOptions {
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
export declare function fetchJson<T>(url: string, token: string, validator: Validator<TProperties, TSchema>, signal?: AbortSignal, opts?: FetchJsonOptions): Promise<T>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport only (ported from pi-bifrost-gateway 0.3.0 @ 5d15a28, module
|
|
3
|
+
* unchanged except the User-Agent name): GET with Bearer auth + typebox
|
|
4
|
+
* validation, a hard 15 s deadline so a silent peer can never hang the
|
|
5
|
+
* refresh.
|
|
6
|
+
*/
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
function packageVersion() {
|
|
10
|
+
const payload = require("../package.json");
|
|
11
|
+
if (typeof payload === "object" && payload !== null) {
|
|
12
|
+
const version = payload.version;
|
|
13
|
+
if (typeof version === "string" && version.length > 0)
|
|
14
|
+
return version;
|
|
15
|
+
}
|
|
16
|
+
return "0.0.0";
|
|
17
|
+
}
|
|
18
|
+
// The wire identity: the repo/product name (the npm name minus its
|
|
19
|
+
// scope), stable across a package rename — upstreams key logs on it.
|
|
20
|
+
const USER_AGENT = `sigrun-provider-bifrost/${packageVersion()}`;
|
|
21
|
+
export class HttpError extends Error {
|
|
22
|
+
status;
|
|
23
|
+
code;
|
|
24
|
+
constructor(status, code, message) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.status = status;
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.name = HttpError.name;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* GET `url` with Bearer auth and validate the JSON body with `validator`.
|
|
33
|
+
* Non-2xx responses throw HttpError carrying the upstream
|
|
34
|
+
* `error.code`/`error.message` when present. Abort errors propagate untouched.
|
|
35
|
+
*
|
|
36
|
+
* Each GET also carries a default 15 s deadline (AbortSignal.timeout,
|
|
37
|
+
* combined with any caller signal — the caller's abort still wins). Without
|
|
38
|
+
* it a peer that accepts the connection and then goes silent hangs the
|
|
39
|
+
* refresh until the process dies; refresh MUST terminate.
|
|
40
|
+
*/
|
|
41
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
42
|
+
export async function fetchJson(url, token, validator, signal, opts) {
|
|
43
|
+
const doFetch = opts?.fetchImpl ?? globalThis.fetch;
|
|
44
|
+
const deadline = AbortSignal.any([AbortSignal.timeout(DEFAULT_TIMEOUT_MS), ...(signal ? [signal] : [])]);
|
|
45
|
+
const response = await doFetch(url, {
|
|
46
|
+
method: "GET",
|
|
47
|
+
headers: {
|
|
48
|
+
Authorization: `Bearer ${token}`,
|
|
49
|
+
"User-Agent": USER_AGENT,
|
|
50
|
+
Accept: "application/json",
|
|
51
|
+
},
|
|
52
|
+
signal: deadline,
|
|
53
|
+
});
|
|
54
|
+
const body = await response.text();
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
const { code, message } = parseErrorBody(body);
|
|
57
|
+
throw new HttpError(response.status, code === "http_error" ? `http_${response.status}` : code, message.length > 0 ? message : `${response.status} from ${url}`);
|
|
58
|
+
}
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(body);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw new HttpError(response.status, "schema_mismatch", `invalid JSON from ${url}: ${String(error)}`);
|
|
65
|
+
}
|
|
66
|
+
if (!validator.Check(parsed)) {
|
|
67
|
+
const detail = validator
|
|
68
|
+
.Errors(parsed)
|
|
69
|
+
.map((e) => `${e.instancePath}: ${e.message}`)
|
|
70
|
+
.join("; ");
|
|
71
|
+
throw new HttpError(response.status, "schema_mismatch", `schema mismatch from ${url}: ${detail}`);
|
|
72
|
+
}
|
|
73
|
+
return validator.Decode(parsed);
|
|
74
|
+
}
|
|
75
|
+
function parseErrorBody(body) {
|
|
76
|
+
try {
|
|
77
|
+
const payload = JSON.parse(body);
|
|
78
|
+
const error = payload?.error;
|
|
79
|
+
if (typeof error === "object" && error !== null && typeof error.message === "string") {
|
|
80
|
+
const code = typeof error.code === "string" && error.code.length > 0 ? error.code : "http_error";
|
|
81
|
+
return { code, message: error.message };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// non-JSON error body — fall through
|
|
86
|
+
}
|
|
87
|
+
return { code: "http_error", message: body.slice(0, 300) };
|
|
88
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sigrun/provider-bifrost — the big port (provider plugins Task 10):
|
|
3
|
+
* [Bifrost](https://github.com/maximhq/bifrost) LLM gateway as one dynamic
|
|
4
|
+
* provider, ported from pi-bifrost-gateway 0.3.0 @ 5d15a28. All failover,
|
|
5
|
+
* key selection, and routing happens in Bifrost — this plugin never routes
|
|
6
|
+
* anything itself.
|
|
7
|
+
*
|
|
8
|
+
* The default export is the sigrun extension-package factory returning a
|
|
9
|
+
* PROVIDER-ONLY contributor — `{ name, providers, health }` with no
|
|
10
|
+
* `extension` field — consumed structurally by sigrun's loader (Task 1
|
|
11
|
+
* pathway). The port's two deliberate adaptations:
|
|
12
|
+
*
|
|
13
|
+
* - CONFIG: pi's config file becomes the plugin's SCOPED SETTINGS
|
|
14
|
+
* (`settings/provider-bifrost.json`), read ONCE at factory time —
|
|
15
|
+
* restart-required (sigrun's loader pre-keys the factory host on the
|
|
16
|
+
* provisional slug so the factory-time read resolves this package's
|
|
17
|
+
* file). No /login flow; no pi config file.
|
|
18
|
+
* - CREDENTIALS: the VK comes from sigrun's auth.json bridge (the stored
|
|
19
|
+
* credential arrives as `credential` on the auth resolver) with
|
|
20
|
+
* `BIFROST_API_KEY` as the ambient fallback; the ambient base URL
|
|
21
|
+
* (`BIFROST_BASE_URL`) resolves through the same env seam. The plugin
|
|
22
|
+
* never reads auth.json or `process.env` itself.
|
|
23
|
+
*
|
|
24
|
+
* Two discovery modes (settings `publishChains`, default false):
|
|
25
|
+
* - chains mode: the management plane is queried — VK self-identification
|
|
26
|
+
* (`/api/governance/virtual-keys`) + routing rules
|
|
27
|
+
* (`/api/routing/rules`) — and a rules set with no usable chains fails
|
|
28
|
+
* discovery loud.
|
|
29
|
+
* - member-only mode (default): the inference plane alone (`GET /v1/models`);
|
|
30
|
+
* ZERO management calls, so it works against a stock gateway with
|
|
31
|
+
* governance disabled entirely.
|
|
32
|
+
*
|
|
33
|
+
* `fetchModels` THROWS on unconfigured (no key or no base URL), on a bad
|
|
34
|
+
* credential/VK mismatch, and on zero chains — it NEVER returns `[]`,
|
|
35
|
+
* which pi-ai would publish and persist, wiping the catalog.
|
|
36
|
+
*
|
|
37
|
+
* Runtime imports from pi-ai are deliberate (`createProvider` + the three
|
|
38
|
+
* api surfaces); the package declares pi-ai/pi-durable as devDependencies
|
|
39
|
+
* only, because sigrun (the host) ships them and `syncPackages` installs
|
|
40
|
+
* without dev dependencies — the provider-plugins spec's dependency
|
|
41
|
+
* posture.
|
|
42
|
+
*/
|
|
43
|
+
import { type Provider } from "@earendil-works/pi-ai/models";
|
|
44
|
+
import { type NativeProtocols } from "./config.js";
|
|
45
|
+
import { PROVIDER_DISPLAY_NAME, PROVIDER_ID, UNRESOLVED_BASE_URL, type GatewayModel } from "./catalog.js";
|
|
46
|
+
export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, UNRESOLVED_BASE_URL };
|
|
47
|
+
export { parseBifrostSettings, BIFROST_BASE_URL_ENV } from "./config.js";
|
|
48
|
+
export type { BifrostSettings, NativeProtocols } from "./config.js";
|
|
49
|
+
export type { GatewayModel, SurfaceApi, CatalogModel, Chain, RoutingRule } from "./catalog.js";
|
|
50
|
+
/** Ambient fallback for the virtual key when auth.json holds no credential. */
|
|
51
|
+
export declare const BIFROST_API_KEY_ENV = "BIFROST_API_KEY";
|
|
52
|
+
/**
|
|
53
|
+
* Structural slice of sigrun's seam host (src/harness/seam-host.ts) a
|
|
54
|
+
* provider plugin may consume. Declared here — structurally, like the
|
|
55
|
+
* bridge's pi-ai re-declarations — because the plugin cannot import
|
|
56
|
+
* sigrun core. The scoped settings store is async-only, which is why the
|
|
57
|
+
* loader awaits factory results (and why this plugin's factory is async).
|
|
58
|
+
*/
|
|
59
|
+
export interface SigrunPluginHost {
|
|
60
|
+
readonly stores: {
|
|
61
|
+
/** This plugin's own `settings/<slug>.json`; absent file → `{}`. */
|
|
62
|
+
readonly settings: {
|
|
63
|
+
get(): Promise<Record<string, unknown>>;
|
|
64
|
+
set(value: Record<string, unknown>): Promise<void>;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Sigrun's three-state per-provider readiness vocabulary (Task 6). */
|
|
69
|
+
export type ProviderReadinessStatus = "not_configured" | "degraded" | "ready";
|
|
70
|
+
/** One tracker report: the status plus optional operator-facing detail. */
|
|
71
|
+
export interface ProviderReadiness {
|
|
72
|
+
readonly status: ProviderReadinessStatus;
|
|
73
|
+
readonly detail?: string;
|
|
74
|
+
}
|
|
75
|
+
/** Sigrun's §3 health vocabulary. */
|
|
76
|
+
export type HealthStatus = "ok" | "degraded";
|
|
77
|
+
/**
|
|
78
|
+
* The provider-plugin factory result (the loader's Task 1 contract):
|
|
79
|
+
* a provider-only contributor — `{ name, providers, health? }`, no
|
|
80
|
+
* `extension`. Structural, like the host slice above.
|
|
81
|
+
*/
|
|
82
|
+
export interface ProviderPluginResult {
|
|
83
|
+
readonly name: string;
|
|
84
|
+
readonly providers: readonly {
|
|
85
|
+
readonly provider: Provider;
|
|
86
|
+
readonly readiness?: () => ProviderReadiness;
|
|
87
|
+
}[];
|
|
88
|
+
readonly health?: () => HealthStatus;
|
|
89
|
+
}
|
|
90
|
+
/** The options `createBifrostPlugin` bakes from factory-time settings. */
|
|
91
|
+
export interface BifrostPluginOptions {
|
|
92
|
+
publishChains: boolean;
|
|
93
|
+
publishUpstream: boolean;
|
|
94
|
+
/** Settings-pinned base URL; ambient env fallback applies when absent. */
|
|
95
|
+
baseUrl?: string;
|
|
96
|
+
managementApiKey?: string;
|
|
97
|
+
nativeProtocols?: NativeProtocols;
|
|
98
|
+
thinkingOverrides?: Record<string, string[]>;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Compose the gateway model list from live Bifrost state (the ported
|
|
102
|
+
* `discoverModels` with sigrun's failure semantics — throws instead of
|
|
103
|
+
* returning `[]` on unconfigured, so a misconfigured deployment surfaces
|
|
104
|
+
* instead of silently wiping the catalog):
|
|
105
|
+
*
|
|
106
|
+
* - chains mode (`publishChains`): VK self-identification + routing rules
|
|
107
|
+
* via the MANAGEMENT token (`managementApiKey` ?? the VK), then catalog +
|
|
108
|
+
* rules in parallel (catalog with the VK itself). Zero usable chains
|
|
109
|
+
* throws — is chains-first routing configured for this VK?
|
|
110
|
+
* - member-only mode: inference plane alone; both publish flags off throws
|
|
111
|
+
* (a configured provider publishing nothing is a settings error, and
|
|
112
|
+
* `fetchModels` never returns `[]`).
|
|
113
|
+
*
|
|
114
|
+
* Throws when the credential is not a recognized VK (chains mode) or when
|
|
115
|
+
* discovery itself fails (unreachable gateway, schema mismatch).
|
|
116
|
+
*/
|
|
117
|
+
export declare function discoverModels(token: string | undefined, baseUrl: string | undefined, options: BifrostPluginOptions, signal?: AbortSignal): Promise<GatewayModel[]>;
|
|
118
|
+
/** One built plugin: the contribution's parts, sharing the tracker closure. */
|
|
119
|
+
export interface BifrostPlugin {
|
|
120
|
+
readonly provider: Provider;
|
|
121
|
+
/** Synchronous read over plugin-owned state (never network/storage). */
|
|
122
|
+
readonly readiness: () => ProviderReadiness;
|
|
123
|
+
readonly health: () => HealthStatus;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Build the Bifrost provider plus its readiness/health trackers.
|
|
127
|
+
* `options` are the FACTORY-BAKED settings (restart-required); the ambient
|
|
128
|
+
* `BIFROST_BASE_URL` resolves at credential-resolution time through the
|
|
129
|
+
* env seam and falls below the baked `baseUrl` in precedence.
|
|
130
|
+
*/
|
|
131
|
+
export declare function createBifrostPlugin(options: BifrostPluginOptions): BifrostPlugin;
|
|
132
|
+
/**
|
|
133
|
+
* The sigrun extension-package factory (provider-only contributor).
|
|
134
|
+
* Arity 1 declares the host-taking signature — sigrun's loader passes the
|
|
135
|
+
* seam host only to factories that declare one (`factory.length > 0`) —
|
|
136
|
+
* and the factory is ASYNC because the scoped settings store is
|
|
137
|
+
* async-only; sigrun's loader awaits factory results.
|
|
138
|
+
*
|
|
139
|
+
* Settings are read ONCE here and BAKED into the provider (the
|
|
140
|
+
* restart-required semantics: the loader pre-keys the host on the
|
|
141
|
+
* provisional slug — this package's manifest name — so the factory-time
|
|
142
|
+
* read resolves `settings/provider-bifrost.json`). Parse issues log via
|
|
143
|
+
* `console.warn` and never quarantine (a wrong-typed key falls back to
|
|
144
|
+
* its default, exactly like the pi port's issue list).
|
|
145
|
+
*/
|
|
146
|
+
export default function sigrunBifrost(host: SigrunPluginHost): Promise<ProviderPluginResult>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @sigrun/provider-bifrost — the big port (provider plugins Task 10):
|
|
3
|
+
* [Bifrost](https://github.com/maximhq/bifrost) LLM gateway as one dynamic
|
|
4
|
+
* provider, ported from pi-bifrost-gateway 0.3.0 @ 5d15a28. All failover,
|
|
5
|
+
* key selection, and routing happens in Bifrost — this plugin never routes
|
|
6
|
+
* anything itself.
|
|
7
|
+
*
|
|
8
|
+
* The default export is the sigrun extension-package factory returning a
|
|
9
|
+
* PROVIDER-ONLY contributor — `{ name, providers, health }` with no
|
|
10
|
+
* `extension` field — consumed structurally by sigrun's loader (Task 1
|
|
11
|
+
* pathway). The port's two deliberate adaptations:
|
|
12
|
+
*
|
|
13
|
+
* - CONFIG: pi's config file becomes the plugin's SCOPED SETTINGS
|
|
14
|
+
* (`settings/provider-bifrost.json`), read ONCE at factory time —
|
|
15
|
+
* restart-required (sigrun's loader pre-keys the factory host on the
|
|
16
|
+
* provisional slug so the factory-time read resolves this package's
|
|
17
|
+
* file). No /login flow; no pi config file.
|
|
18
|
+
* - CREDENTIALS: the VK comes from sigrun's auth.json bridge (the stored
|
|
19
|
+
* credential arrives as `credential` on the auth resolver) with
|
|
20
|
+
* `BIFROST_API_KEY` as the ambient fallback; the ambient base URL
|
|
21
|
+
* (`BIFROST_BASE_URL`) resolves through the same env seam. The plugin
|
|
22
|
+
* never reads auth.json or `process.env` itself.
|
|
23
|
+
*
|
|
24
|
+
* Two discovery modes (settings `publishChains`, default false):
|
|
25
|
+
* - chains mode: the management plane is queried — VK self-identification
|
|
26
|
+
* (`/api/governance/virtual-keys`) + routing rules
|
|
27
|
+
* (`/api/routing/rules`) — and a rules set with no usable chains fails
|
|
28
|
+
* discovery loud.
|
|
29
|
+
* - member-only mode (default): the inference plane alone (`GET /v1/models`);
|
|
30
|
+
* ZERO management calls, so it works against a stock gateway with
|
|
31
|
+
* governance disabled entirely.
|
|
32
|
+
*
|
|
33
|
+
* `fetchModels` THROWS on unconfigured (no key or no base URL), on a bad
|
|
34
|
+
* credential/VK mismatch, and on zero chains — it NEVER returns `[]`,
|
|
35
|
+
* which pi-ai would publish and persist, wiping the catalog.
|
|
36
|
+
*
|
|
37
|
+
* Runtime imports from pi-ai are deliberate (`createProvider` + the three
|
|
38
|
+
* api surfaces); the package declares pi-ai/pi-durable as devDependencies
|
|
39
|
+
* only, because sigrun (the host) ships them and `syncPackages` installs
|
|
40
|
+
* without dev dependencies — the provider-plugins spec's dependency
|
|
41
|
+
* posture.
|
|
42
|
+
*/
|
|
43
|
+
import { createProvider } from "@earendil-works/pi-ai/models";
|
|
44
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
45
|
+
import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
|
|
46
|
+
import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
|
|
47
|
+
import { BIFROST_BASE_URL_ENV, parseBifrostSettings, } from "./config.js";
|
|
48
|
+
import { PROVIDER_DISPLAY_NAME, PROVIDER_ID, UNRESOLVED_BASE_URL, buildAllModels, wireCatalog, wireDiscovery, wireRulesToChains, wireScopeId, } from "./catalog.js";
|
|
49
|
+
// Re-exported gateway constants (the pi port's src/bifrost.ts surface):
|
|
50
|
+
// the provider id is part of this plugin's public contract — sigrun's
|
|
51
|
+
// auth-check names providers by id and tests pin the model stamp to it.
|
|
52
|
+
export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, UNRESOLVED_BASE_URL };
|
|
53
|
+
// Re-exported discovery/config types so consumers (and tests) need no
|
|
54
|
+
// deep imports.
|
|
55
|
+
export { parseBifrostSettings, BIFROST_BASE_URL_ENV } from "./config.js";
|
|
56
|
+
/** Ambient fallback for the virtual key when auth.json holds no credential. */
|
|
57
|
+
export const BIFROST_API_KEY_ENV = "BIFROST_API_KEY";
|
|
58
|
+
/**
|
|
59
|
+
* Compose the gateway model list from live Bifrost state (the ported
|
|
60
|
+
* `discoverModels` with sigrun's failure semantics — throws instead of
|
|
61
|
+
* returning `[]` on unconfigured, so a misconfigured deployment surfaces
|
|
62
|
+
* instead of silently wiping the catalog):
|
|
63
|
+
*
|
|
64
|
+
* - chains mode (`publishChains`): VK self-identification + routing rules
|
|
65
|
+
* via the MANAGEMENT token (`managementApiKey` ?? the VK), then catalog +
|
|
66
|
+
* rules in parallel (catalog with the VK itself). Zero usable chains
|
|
67
|
+
* throws — is chains-first routing configured for this VK?
|
|
68
|
+
* - member-only mode: inference plane alone; both publish flags off throws
|
|
69
|
+
* (a configured provider publishing nothing is a settings error, and
|
|
70
|
+
* `fetchModels` never returns `[]`).
|
|
71
|
+
*
|
|
72
|
+
* Throws when the credential is not a recognized VK (chains mode) or when
|
|
73
|
+
* discovery itself fails (unreachable gateway, schema mismatch).
|
|
74
|
+
*/
|
|
75
|
+
export async function discoverModels(token, baseUrl, options, signal) {
|
|
76
|
+
if (!token || !baseUrl) {
|
|
77
|
+
throw new Error("provider-bifrost: the gateway is not configured " +
|
|
78
|
+
`(need a virtual key (auth.json or ${BIFROST_API_KEY_ENV}) and a base URL ` +
|
|
79
|
+
`(settings baseUrl or ${BIFROST_BASE_URL_ENV})); refusing to publish an empty catalog`);
|
|
80
|
+
}
|
|
81
|
+
if (options.publishChains) {
|
|
82
|
+
const managementToken = options.managementApiKey ?? token;
|
|
83
|
+
const scopeId = await wireScopeId(managementToken, token, baseUrl, signal);
|
|
84
|
+
if (!scopeId) {
|
|
85
|
+
throw new Error("provider-bifrost: credential does not match any virtual key in /api/governance/virtual-keys");
|
|
86
|
+
}
|
|
87
|
+
const { catalog, rules } = await wireDiscovery(managementToken, token, baseUrl, scopeId, signal);
|
|
88
|
+
const chains = wireRulesToChains(rules);
|
|
89
|
+
if (chains.length === 0) {
|
|
90
|
+
throw new Error(`provider-bifrost: no chain routing rules found for ${scopeId} — is chains-first routing configured for this VK?`);
|
|
91
|
+
}
|
|
92
|
+
const models = buildAllModels(chains, catalog, PROVIDER_ID, baseUrl, true, options.publishUpstream, options.thinkingOverrides, options.nativeProtocols);
|
|
93
|
+
if (models.length === 0) {
|
|
94
|
+
throw new Error("provider-bifrost: chains discovery produced no models; refusing to publish an empty catalog");
|
|
95
|
+
}
|
|
96
|
+
return models;
|
|
97
|
+
}
|
|
98
|
+
// Member-only mode: inference plane only, ZERO management calls.
|
|
99
|
+
if (!options.publishUpstream) {
|
|
100
|
+
throw new Error("provider-bifrost: publishChains and publishUpstream are both false — nothing to publish");
|
|
101
|
+
}
|
|
102
|
+
const catalog = await wireCatalog(token, baseUrl, signal);
|
|
103
|
+
const models = buildAllModels([], catalog, PROVIDER_ID, baseUrl, false, true, options.thinkingOverrides, options.nativeProtocols);
|
|
104
|
+
if (models.length === 0) {
|
|
105
|
+
throw new Error("provider-bifrost: /v1/models returned no usable entries; refusing to publish an empty catalog");
|
|
106
|
+
}
|
|
107
|
+
return models;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Build the Bifrost provider plus its readiness/health trackers.
|
|
111
|
+
* `options` are the FACTORY-BAKED settings (restart-required); the ambient
|
|
112
|
+
* `BIFROST_BASE_URL` resolves at credential-resolution time through the
|
|
113
|
+
* env seam and falls below the baked `baseUrl` in precedence.
|
|
114
|
+
*/
|
|
115
|
+
export function createBifrostPlugin(options) {
|
|
116
|
+
// Tracker state: seeded from credential resolution (pi-ai never
|
|
117
|
+
// refreshes an unconfigured provider, so refresh outcomes alone cannot
|
|
118
|
+
// express not_configured), updated per refresh outcome. Readiness and
|
|
119
|
+
// health are synchronous derivations over this state.
|
|
120
|
+
let credentialResolved = false;
|
|
121
|
+
let lastRefreshFailed = false;
|
|
122
|
+
let lastRefreshDetail;
|
|
123
|
+
let lastAnnounced;
|
|
124
|
+
// The ambient base URL, captured at credential resolution (the only
|
|
125
|
+
// place the env seam is available); the baked settings URL outranks it.
|
|
126
|
+
let ambientBaseUrl;
|
|
127
|
+
function status() {
|
|
128
|
+
if (!credentialResolved)
|
|
129
|
+
return "not_configured";
|
|
130
|
+
return lastRefreshFailed ? "degraded" : "ready";
|
|
131
|
+
}
|
|
132
|
+
/** Apply a state change; `console.warn` on transitions only. */
|
|
133
|
+
function announce() {
|
|
134
|
+
const next = status();
|
|
135
|
+
if (next === lastAnnounced)
|
|
136
|
+
return;
|
|
137
|
+
console.warn(`provider-bifrost: bifrost ${lastAnnounced ?? "startup"} → ${next}` +
|
|
138
|
+
(lastRefreshDetail !== undefined && next === "degraded"
|
|
139
|
+
? ` (${lastRefreshDetail})`
|
|
140
|
+
: ""));
|
|
141
|
+
lastAnnounced = next;
|
|
142
|
+
}
|
|
143
|
+
function noteCredential(resolved) {
|
|
144
|
+
credentialResolved = resolved;
|
|
145
|
+
announce();
|
|
146
|
+
}
|
|
147
|
+
function noteRefresh(ok, detail) {
|
|
148
|
+
lastRefreshFailed = !ok;
|
|
149
|
+
lastRefreshDetail = ok ? undefined : detail;
|
|
150
|
+
announce();
|
|
151
|
+
}
|
|
152
|
+
const provider = createProvider({
|
|
153
|
+
id: PROVIDER_ID,
|
|
154
|
+
name: PROVIDER_DISPLAY_NAME,
|
|
155
|
+
// UNRESOLVED_BASE_URL satisfies createProvider's shape before any
|
|
156
|
+
// URL exists; it is never requested — discovery refuses to run
|
|
157
|
+
// without a real URL, and every published model carries its own
|
|
158
|
+
// discovery-time baseUrl.
|
|
159
|
+
baseUrl: options.baseUrl ?? UNRESOLVED_BASE_URL,
|
|
160
|
+
// REQUIRED baseline: createProvider iterates it (omitting throws
|
|
161
|
+
// "baselineModels is not iterable"); empty = purely dynamic. An
|
|
162
|
+
// unconfigured provider boots with zero models.
|
|
163
|
+
models: [],
|
|
164
|
+
auth: {
|
|
165
|
+
apiKey: {
|
|
166
|
+
name: `Bifrost virtual key (auth.json or ${BIFROST_API_KEY_ENV})`,
|
|
167
|
+
resolve: async ({ credential, ctx }) => {
|
|
168
|
+
// Ambient base-URL capture happens on every resolution —
|
|
169
|
+
// the env seam is only available here, and like the key
|
|
170
|
+
// itself, rotating it applies on the next resolution.
|
|
171
|
+
const envUrl = (await ctx.env(BIFROST_BASE_URL_ENV))?.trim() || undefined;
|
|
172
|
+
if (envUrl !== undefined)
|
|
173
|
+
ambientBaseUrl = envUrl;
|
|
174
|
+
// Bridged auth.json credential (sigrun's bridge: the
|
|
175
|
+
// "default" entry first, then the provider's own) wins;
|
|
176
|
+
// the env var is the ambient fallback.
|
|
177
|
+
const stored = credential?.key?.trim() || undefined;
|
|
178
|
+
if (stored !== undefined) {
|
|
179
|
+
noteCredential(true);
|
|
180
|
+
return { auth: { apiKey: stored }, source: "auth.json" };
|
|
181
|
+
}
|
|
182
|
+
const envKey = (await ctx.env(BIFROST_API_KEY_ENV))?.trim() || undefined;
|
|
183
|
+
if (envKey !== undefined) {
|
|
184
|
+
noteCredential(true);
|
|
185
|
+
return { auth: { apiKey: envKey }, source: BIFROST_API_KEY_ENV };
|
|
186
|
+
}
|
|
187
|
+
// Unconfigured: undefined makes pi-ai skip the refresh
|
|
188
|
+
// phase entirely and requests fail loud.
|
|
189
|
+
noteCredential(false);
|
|
190
|
+
return undefined;
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
// Native-protocol surfaces: createProvider dispatches on model.api —
|
|
195
|
+
// anthropic-messages for claude-family models (wired to the gateway's
|
|
196
|
+
// /anthropic integration route), openai-responses for gpt-family
|
|
197
|
+
// models, chat completions for everything else. Which models get a
|
|
198
|
+
// native wire is settings-gated (nativeProtocols; both off by
|
|
199
|
+
// default). The map always registers all three: the registry entry
|
|
200
|
+
// is inert for models that never publish that api.
|
|
201
|
+
api: {
|
|
202
|
+
"openai-completions": openAICompletionsApi(),
|
|
203
|
+
"openai-responses": openAIResponsesApi(),
|
|
204
|
+
"anthropic-messages": anthropicMessagesApi(),
|
|
205
|
+
},
|
|
206
|
+
fetchModels: async (context) => {
|
|
207
|
+
const key = context.credential?.type === "api_key"
|
|
208
|
+
? context.credential.key?.trim() || undefined
|
|
209
|
+
: undefined;
|
|
210
|
+
// URL precedence: the factory-baked settings URL wins, then the
|
|
211
|
+
// ambient BIFROST_BASE_URL captured at credential resolution.
|
|
212
|
+
// context.baseUrl is IGNORED — it echoes this provider's
|
|
213
|
+
// registration URL, which may be the never-requested placeholder.
|
|
214
|
+
const url = options.baseUrl ?? ambientBaseUrl;
|
|
215
|
+
try {
|
|
216
|
+
const models = await discoverModels(key, url, options, context.signal);
|
|
217
|
+
noteRefresh(true);
|
|
218
|
+
return models;
|
|
219
|
+
}
|
|
220
|
+
catch (cause) {
|
|
221
|
+
// An aborted refresh is not a failed one: pi-ai discards
|
|
222
|
+
// the outcome anyway, so the tracker must not move.
|
|
223
|
+
if (context.signal.aborted)
|
|
224
|
+
throw cause;
|
|
225
|
+
if (key === undefined)
|
|
226
|
+
noteCredential(false);
|
|
227
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
228
|
+
noteRefresh(false, message);
|
|
229
|
+
throw cause;
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
return {
|
|
234
|
+
provider,
|
|
235
|
+
readiness: () => ({
|
|
236
|
+
status: status(),
|
|
237
|
+
...(lastRefreshDetail !== undefined && status() === "degraded"
|
|
238
|
+
? { detail: lastRefreshDetail }
|
|
239
|
+
: {}),
|
|
240
|
+
}),
|
|
241
|
+
// not_configured → ok (an unconfigured optional plugin is not a
|
|
242
|
+
// degraded process); configured-and-failing → degraded; else ok.
|
|
243
|
+
health: () => (status() === "degraded" ? "degraded" : "ok"),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The sigrun extension-package factory (provider-only contributor).
|
|
248
|
+
* Arity 1 declares the host-taking signature — sigrun's loader passes the
|
|
249
|
+
* seam host only to factories that declare one (`factory.length > 0`) —
|
|
250
|
+
* and the factory is ASYNC because the scoped settings store is
|
|
251
|
+
* async-only; sigrun's loader awaits factory results.
|
|
252
|
+
*
|
|
253
|
+
* Settings are read ONCE here and BAKED into the provider (the
|
|
254
|
+
* restart-required semantics: the loader pre-keys the host on the
|
|
255
|
+
* provisional slug — this package's manifest name — so the factory-time
|
|
256
|
+
* read resolves `settings/provider-bifrost.json`). Parse issues log via
|
|
257
|
+
* `console.warn` and never quarantine (a wrong-typed key falls back to
|
|
258
|
+
* its default, exactly like the pi port's issue list).
|
|
259
|
+
*/
|
|
260
|
+
export default async function sigrunBifrost(host) {
|
|
261
|
+
const raw = await host.stores.settings.get();
|
|
262
|
+
const issues = [];
|
|
263
|
+
const settings = parseBifrostSettings(raw, issues);
|
|
264
|
+
for (const issue of issues) {
|
|
265
|
+
console.warn(`provider-bifrost: settings/provider-bifrost.json: ${issue}`);
|
|
266
|
+
}
|
|
267
|
+
const plugin = createBifrostPlugin({
|
|
268
|
+
publishChains: settings.publishChains,
|
|
269
|
+
publishUpstream: settings.publishUpstream,
|
|
270
|
+
baseUrl: settings.baseUrl,
|
|
271
|
+
managementApiKey: settings.managementApiKey,
|
|
272
|
+
nativeProtocols: settings.nativeProtocols,
|
|
273
|
+
thinkingOverrides: settings.thinkingOverrides,
|
|
274
|
+
});
|
|
275
|
+
return {
|
|
276
|
+
name: "provider-bifrost",
|
|
277
|
+
providers: [{ provider: plugin.provider, readiness: plugin.readiness }],
|
|
278
|
+
health: plugin.health,
|
|
279
|
+
};
|
|
280
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@sigrun/provider-bifrost",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Sigrun provider plugin: a Bifrost LLM gateway as one dynamic provider \u2014 chain models from VK routing rules and/or the upstream catalogue, with optional per-family native protocol surfaces",
|
|
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-bifrost && vitest run",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"build": "tsc -p tsconfig.build.json",
|
|
25
|
+
"prepare": "npm run build"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@earendil-works/pi-ai": "1.3.0",
|
|
29
|
+
"@earendil-works/pi-durable": "1.3.0",
|
|
30
|
+
"typebox": "1.3.27",
|
|
31
|
+
"@types/node": "^26.6.4",
|
|
32
|
+
"typescript": "^7.0.2",
|
|
33
|
+
"vitest": "^5.0.3"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "github",
|
|
38
|
+
"url": "git+https://github.com/sigrun-ai/sigrun-provider-bifrost.git"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/sigrun-ai/sigrun-provider-bifrost/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/sigrun-ai/sigrun-provider-bifrost#readme",
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public",
|
|
46
|
+
"provenance": true
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"@earendil-works/pi-ai": "1.3.0",
|
|
50
|
+
"@earendil-works/pi-durable": "1.3.0",
|
|
51
|
+
"typebox": "1.3.27"
|
|
52
|
+
}
|
|
53
|
+
}
|