@homeflare/seat-runtime 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/LICENSE +21 -0
- package/README.md +200 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +508 -0
- package/dist/index.js.map +21 -0
- package/dist/mcp-connect.d.ts +72 -0
- package/dist/mcp-connect.d.ts.map +1 -0
- package/dist/mcp-error.d.ts +77 -0
- package/dist/mcp-error.d.ts.map +1 -0
- package/dist/mcp-pages.d.ts +18 -0
- package/dist/mcp-pages.d.ts.map +1 -0
- package/dist/mcp-render.d.ts +25 -0
- package/dist/mcp-render.d.ts.map +1 -0
- package/dist/mcp-tool.d.ts +61 -0
- package/dist/mcp-tool.d.ts.map +1 -0
- package/dist/mcp-toolkit.d.ts +61 -0
- package/dist/mcp-toolkit.d.ts.map +1 -0
- package/dist/mcp-toolset.d.ts +33 -0
- package/dist/mcp-toolset.d.ts.map +1 -0
- package/dist/rounds.d.ts +108 -0
- package/dist/rounds.d.ts.map +1 -0
- package/dist/seat-model.d.ts +59 -0
- package/dist/seat-model.d.ts.map +1 -0
- package/dist/seat-obs.d.ts +23 -0
- package/dist/seat-obs.d.ts.map +1 -0
- package/dist/seat-state.d.ts +33 -0
- package/dist/seat-state.d.ts.map +1 -0
- package/dist/stamp.d.ts +23 -0
- package/dist/stamp.d.ts.map +1 -0
- package/dist/state-dsn.d.ts +41 -0
- package/dist/state-dsn.d.ts.map +1 -0
- package/dist/state-postgres.d.ts +58 -0
- package/dist/state-postgres.d.ts.map +1 -0
- package/dist/state-valkey-connection.d.ts +49 -0
- package/dist/state-valkey-connection.d.ts.map +1 -0
- package/dist/state-valkey-scrub.d.ts +37 -0
- package/dist/state-valkey-scrub.d.ts.map +1 -0
- package/dist/state-valkey-send.d.ts +35 -0
- package/dist/state-valkey-send.d.ts.map +1 -0
- package/dist/state-valkey.d.ts +83 -0
- package/dist/state-valkey.d.ts.map +1 -0
- package/dist/state.d.ts +9 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/state.js +327 -0
- package/dist/state.js.map +16 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/docs/mcp.md +40 -0
- package/docs/pairing.md +39 -0
- package/docs/state.md +174 -0
- package/package.json +45 -0
- package/src/index.ts +25 -0
- package/src/mcp-connect.ts +172 -0
- package/src/mcp-error.ts +150 -0
- package/src/mcp-pages.ts +37 -0
- package/src/mcp-render.ts +67 -0
- package/src/mcp-tool.ts +101 -0
- package/src/mcp-toolkit.ts +183 -0
- package/src/mcp-toolset.ts +91 -0
- package/src/rounds.ts +211 -0
- package/src/seat-model.ts +94 -0
- package/src/seat-obs.ts +103 -0
- package/src/seat-state.ts +53 -0
- package/src/stamp.ts +88 -0
- package/src/state-dsn.ts +112 -0
- package/src/state-postgres.ts +114 -0
- package/src/state-valkey-connection.ts +113 -0
- package/src/state-valkey-scrub.ts +60 -0
- package/src/state-valkey-send.ts +67 -0
- package/src/state-valkey.ts +193 -0
- package/src/state.ts +8 -0
- package/src/version.ts +2 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LiteLLM as an Effect `LanguageModel` and `EmbeddingModel`, on the chat-completions wire.
|
|
3
|
+
*
|
|
4
|
+
* ★ ONE CLIENT SHAPE FOR EVERY SEAT. `@effect/ai-openai-compat` builds the HTTP client;
|
|
5
|
+
* this file adds only what LiteLLM needs to attribute and control a seat's calls (tags,
|
|
6
|
+
* the response cache, retries, metadata — see stamp.ts) and pins `strictJsonSchema: false`.
|
|
7
|
+
* ⛔ THE COMPAT PACKAGE POSTS `/chat/completions` AND `/embeddings`, NOT `/responses`. The
|
|
8
|
+
* responses wire fails on cf-code with `missing field sequence_number` (landscape PR 165),
|
|
9
|
+
* so nothing here points at it.
|
|
10
|
+
*/
|
|
11
|
+
import { OpenAiClient, OpenAiEmbeddingModel, OpenAiLanguageModel } from '@effect/ai-openai-compat';
|
|
12
|
+
import * as Layer from 'effect/Layer';
|
|
13
|
+
import * as Redacted from 'effect/Redacted';
|
|
14
|
+
import * as EmbeddingModel from 'effect/unstable/ai/EmbeddingModel';
|
|
15
|
+
import type * as LanguageModel from 'effect/unstable/ai/LanguageModel';
|
|
16
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
17
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
18
|
+
import { joinTags, stampRequest } from './stamp.ts';
|
|
19
|
+
|
|
20
|
+
export type SeatClientOptions = {
|
|
21
|
+
/** LiteLLM's OpenAI-compatible base, e.g. `http://127.0.0.1:4100/v1`. Required: no host default. */
|
|
22
|
+
readonly apiUrl: string;
|
|
23
|
+
/** A per-seat LiteLLM virtual key. Held `Redacted`; this package never logs or reads it from disk. */
|
|
24
|
+
readonly apiKey: string | Redacted.Redacted<string>;
|
|
25
|
+
/** Spend-log attribution, e.g. `['host:ct100', 'lane:cfcode', 'seat:cf-coding']`. */
|
|
26
|
+
readonly tags: string | ReadonlyArray<string>;
|
|
27
|
+
/**
|
|
28
|
+
* Skip LiteLLM's response cache, read and write. Defaults to TRUE.
|
|
29
|
+
* ⚠️ LiteLLM caches every completion for every key, so a seat that repeats a call gets the
|
|
30
|
+
* old answer back at a tenth of the latency — an agent loop would replay itself.
|
|
31
|
+
*/
|
|
32
|
+
readonly noCache?: boolean | undefined;
|
|
33
|
+
/** Written into the request body only when the body carries none (compat drops it). */
|
|
34
|
+
readonly metadata?: Readonly<Record<string, string>> | undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Compat's own per-model config (`temperature`, `max_output_tokens`, custom body keys). */
|
|
38
|
+
type ModelConfig = NonNullable<Parameters<typeof OpenAiLanguageModel.layer>[0]['config']>;
|
|
39
|
+
type EmbeddingConfig = NonNullable<Parameters<typeof OpenAiEmbeddingModel.layer>[0]['config']>;
|
|
40
|
+
|
|
41
|
+
export type SeatModelOptions = SeatClientOptions & {
|
|
42
|
+
/** The LiteLLM alias, e.g. `cf-code`. */
|
|
43
|
+
readonly model: string;
|
|
44
|
+
readonly config?: ModelConfig | undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type SeatEmbeddingOptions = SeatClientOptions & {
|
|
48
|
+
/** The LiteLLM alias, e.g. `embeddings`. */
|
|
49
|
+
readonly model: string;
|
|
50
|
+
/**
|
|
51
|
+
* What `EmbeddingModel.Dimensions` reports to a vector store. NOT sent to LiteLLM.
|
|
52
|
+
* ⚠️ compat's own `OpenAiEmbeddingModel.model(name, { dimensions })` puts the number in the
|
|
53
|
+
* request body too, and a provider that has no `dimensions` parameter (a llama.cpp bge-m3
|
|
54
|
+
* behind LiteLLM) can refuse it. Pass `config: { dimensions }` to send it deliberately.
|
|
55
|
+
*/
|
|
56
|
+
readonly dimensions: number;
|
|
57
|
+
readonly config?: EmbeddingConfig | undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The client: bearer key, tag header, cache and retry fields, over `fetch`. */
|
|
61
|
+
export function clientLayer(options: SeatClientOptions): Layer.Layer<OpenAiClient.OpenAiClient> {
|
|
62
|
+
const stamp = {
|
|
63
|
+
tags: joinTags(options.tags),
|
|
64
|
+
noCache: options.noCache ?? true,
|
|
65
|
+
metadata: options.metadata,
|
|
66
|
+
};
|
|
67
|
+
return OpenAiClient.layer({
|
|
68
|
+
apiKey: Redacted.isRedacted(options.apiKey) ? options.apiKey : Redacted.make(options.apiKey),
|
|
69
|
+
apiUrl: options.apiUrl.replace(/\/$/, ''),
|
|
70
|
+
transformClient: (http) =>
|
|
71
|
+
HttpClient.mapRequest(http, (request) => stampRequest(request, stamp)),
|
|
72
|
+
}).pipe(Layer.provide(FetchHttpClient.layer));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `LanguageModel` for one LiteLLM alias. */
|
|
76
|
+
export function layer(options: SeatModelOptions): Layer.Layer<LanguageModel.LanguageModel> {
|
|
77
|
+
return OpenAiLanguageModel.layer({
|
|
78
|
+
model: options.model,
|
|
79
|
+
// ★ cf-review's default, and measured here 2026-09-29: compat sends `strict: true` on every
|
|
80
|
+
// tool schema unless told otherwise, which the seats' previous client never did; this
|
|
81
|
+
// sends `strict: false`. A caller's `config` wins.
|
|
82
|
+
config: { strictJsonSchema: false, ...options.config },
|
|
83
|
+
}).pipe(Layer.provide(clientLayer(options)));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `EmbeddingModel` (and its `Dimensions`) for one LiteLLM alias. */
|
|
87
|
+
export function embeddingLayer(
|
|
88
|
+
options: SeatEmbeddingOptions,
|
|
89
|
+
): Layer.Layer<EmbeddingModel.EmbeddingModel | EmbeddingModel.Dimensions> {
|
|
90
|
+
return Layer.merge(
|
|
91
|
+
OpenAiEmbeddingModel.layer({ model: options.model, config: options.config }),
|
|
92
|
+
Layer.succeed(EmbeddingModel.Dimensions, options.dimensions),
|
|
93
|
+
).pipe(Layer.provide(clientLayer(options)));
|
|
94
|
+
}
|
package/src/seat-obs.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traces, logs and metrics from any Effect seat to VictoriaMetrics on CT100, from one
|
|
3
|
+
* environment block.
|
|
4
|
+
*
|
|
5
|
+
* ★ `layerFromConfig`, NOT `Otlp.layer`. The combined layer takes ONE base URL and appends
|
|
6
|
+
* `/v1/traces` and friends, and the three Victoria services each mount OTLP at a different
|
|
7
|
+
* path (measured 2026-09-29, README). The per-signal layers read the per-signal env the
|
|
8
|
+
* Claude Code seats already carry, so one block wires every seat.
|
|
9
|
+
* ⛔ WITH NO ENVIRONMENT `layerFromConfig` EXPORTS NOTHING, SILENTLY. It returns a bare
|
|
10
|
+
* flusher unless `OTEL_<SIGNAL>_EXPORTER` names `otlp` and an endpoint is set. So the CT100
|
|
11
|
+
* defaults below are not a convenience, they are what makes this layer emit at all.
|
|
12
|
+
*/
|
|
13
|
+
import * as Config from 'effect/Config';
|
|
14
|
+
import * as ConfigProvider from 'effect/ConfigProvider';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import * as Layer from 'effect/Layer';
|
|
17
|
+
import * as Schema from 'effect/Schema';
|
|
18
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
19
|
+
import {
|
|
20
|
+
type OtlpExporter,
|
|
21
|
+
OtlpLogger,
|
|
22
|
+
OtlpMetrics,
|
|
23
|
+
OtlpSerialization,
|
|
24
|
+
OtlpTracer,
|
|
25
|
+
} from 'effect/unstable/observability';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* CT100's Victoria services, one path each. Verified 2026-09-29 by GET against the live
|
|
29
|
+
* ports (no payload sent): a mounted path answers, an unmounted sibling answers
|
|
30
|
+
* `unsupported path requested`; the counters carry `format="protobuf"` for traces and logs.
|
|
31
|
+
*/
|
|
32
|
+
export const CT100_ENDPOINTS: {
|
|
33
|
+
readonly traces: string;
|
|
34
|
+
readonly logs: string;
|
|
35
|
+
readonly metrics: string;
|
|
36
|
+
} = {
|
|
37
|
+
traces: 'http://10.100.1.4:10428/insert/opentelemetry/v1/traces',
|
|
38
|
+
logs: 'http://10.100.1.4:9428/insert/opentelemetry/v1/logs',
|
|
39
|
+
metrics: 'http://10.100.1.4:8428/opentelemetry/v1/metrics',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Shown when a process names itself neither with `OTEL_SERVICE_NAME` nor with a `service.name`
|
|
44
|
+
* in `OTEL_RESOURCE_ATTRIBUTES`; set one per seat.
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_SERVICE_NAME = 'seat-runtime';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `OTEL_RESOURCE_ATTRIBUTES` read the way Effect reads it (`OtlpResource.fromConfig`, rc.115:
|
|
50
|
+
* `key=value` pairs, both sides URI-decoded), so "has a `service.name`" means what Effect means.
|
|
51
|
+
*/
|
|
52
|
+
const resourceAttributes = Config.Record(
|
|
53
|
+
Schema.StringFromUriComponent,
|
|
54
|
+
Schema.StringFromUriComponent,
|
|
55
|
+
'OTEL_RESOURCE_ATTRIBUTES',
|
|
56
|
+
).pipe(Config.withDefault(undefined));
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The fallback config source, computed against the CURRENT provider.
|
|
60
|
+
*
|
|
61
|
+
* ⛔ EVERYTHING HERE IS A FALLBACK. The environment is tried first, so `OTEL_SDK_DISABLED=true`,
|
|
62
|
+
* `OTEL_TRACES_EXPORTER=none` and every endpoint the operator sets win.
|
|
63
|
+
* ⚠️ THE PER-SIGNAL DEFAULTS STEP ASIDE FOR A BASE ENDPOINT. Effect reads
|
|
64
|
+
* `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` and only then `OTEL_EXPORTER_OTLP_ENDPOINT`, so a
|
|
65
|
+
* per-signal default would shadow an operator's base URL and send their traces to CT100.
|
|
66
|
+
* ⚠️ THE SERVICE NAME STEPS ASIDE FOR `service.name` IN `OTEL_RESOURCE_ATTRIBUTES` FOR THE SAME
|
|
67
|
+
* REASON. Effect resolves `OTEL_SERVICE_NAME`, then that attribute, then fails, so an injected
|
|
68
|
+
* `OTEL_SERVICE_NAME` would win over the operator's attribute and Effect then drops the
|
|
69
|
+
* attribute, leaving their seat mislabelled `seat-runtime` in Victoria with nothing to say why.
|
|
70
|
+
*/
|
|
71
|
+
const defaults: Effect.Effect<ConfigProvider.ConfigProvider> = Effect.gen(function* () {
|
|
72
|
+
const current = yield* ConfigProvider.ConfigProvider;
|
|
73
|
+
const base = yield* current.load(['OTEL_EXPORTER_OTLP_ENDPOINT']);
|
|
74
|
+
const attributes = yield* resourceAttributes.parse(current);
|
|
75
|
+
const named = attributes?.['service.name'] !== undefined;
|
|
76
|
+
return ConfigProvider.fromUnknown({
|
|
77
|
+
OTEL_TRACES_EXPORTER: 'otlp',
|
|
78
|
+
OTEL_LOGS_EXPORTER: 'otlp',
|
|
79
|
+
OTEL_METRICS_EXPORTER: 'otlp',
|
|
80
|
+
...(named ? {} : { OTEL_SERVICE_NAME: DEFAULT_SERVICE_NAME }),
|
|
81
|
+
...(base === undefined
|
|
82
|
+
? {
|
|
83
|
+
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: CT100_ENDPOINTS.traces,
|
|
84
|
+
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: CT100_ENDPOINTS.logs,
|
|
85
|
+
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: CT100_ENDPOINTS.metrics,
|
|
86
|
+
}
|
|
87
|
+
: {}),
|
|
88
|
+
});
|
|
89
|
+
}).pipe(Effect.orDie);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* OTLP tracer, logger and metrics over `fetch`, protobuf on the wire (what VictoriaTraces,
|
|
93
|
+
* VictoriaLogs and VictoriaMetrics ingest, and what the Claude Code seats already send).
|
|
94
|
+
*/
|
|
95
|
+
export const layer: Layer.Layer<OtlpExporter.Flusher> = Layer.mergeAll(
|
|
96
|
+
OtlpTracer.layerFromConfig(),
|
|
97
|
+
OtlpLogger.layerFromConfig(),
|
|
98
|
+
OtlpMetrics.layerFromConfig(),
|
|
99
|
+
).pipe(
|
|
100
|
+
Layer.provide(OtlpSerialization.layerProtobuf),
|
|
101
|
+
Layer.provide(FetchHttpClient.layer),
|
|
102
|
+
Layer.provide(ConfigProvider.layerAdd(defaults)),
|
|
103
|
+
);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `SeatState`: a seat's Postgres and Valkey as Effect services, one layer each, in the trace of
|
|
3
|
+
* the run that uses them.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THE LIBRARY HOLDS NO HOST. Every URL comes from the consumer (a value, or an environment
|
|
6
|
+
* variable it names), as `Redacted`; there is no default address here to point a seat at the
|
|
7
|
+
* wrong store, and nothing reads a credential from disk.
|
|
8
|
+
* ★ THE SERVICES ARE EFFECT'S, NOT OURS: `SqlClient` (`effect/unstable/sql/SqlClient`) for
|
|
9
|
+
* Postgres and `Redis` (`effect/unstable/persistence/Redis`) for Valkey, so a consumer writes
|
|
10
|
+
* ordinary Effect SQL and Redis code and this package adds only how they are built.
|
|
11
|
+
*/
|
|
12
|
+
import * as Layer from 'effect/Layer';
|
|
13
|
+
import type * as Redis from 'effect/unstable/persistence/Redis';
|
|
14
|
+
import type * as SqlClient from 'effect/unstable/sql/SqlClient';
|
|
15
|
+
import type { SqlError } from 'effect/unstable/sql/SqlError';
|
|
16
|
+
import type { Config } from 'effect';
|
|
17
|
+
import type { PgClient } from '@effect/sql-pg';
|
|
18
|
+
import {
|
|
19
|
+
type PostgresFromEnvOptions,
|
|
20
|
+
type PostgresOptions,
|
|
21
|
+
postgres,
|
|
22
|
+
postgresFromEnv,
|
|
23
|
+
} from './state-postgres.ts';
|
|
24
|
+
import {
|
|
25
|
+
type ValkeyFromEnvOptions,
|
|
26
|
+
type ValkeyOptions,
|
|
27
|
+
valkey,
|
|
28
|
+
valkeyFromEnv,
|
|
29
|
+
} from './state-valkey.ts';
|
|
30
|
+
|
|
31
|
+
export * from './state-postgres.ts';
|
|
32
|
+
export * from './state-valkey.ts';
|
|
33
|
+
// ★ RE-EXPORTED so a consumer under pnpm's strict resolution can name `PgClient` (for `.json`,
|
|
34
|
+
// `.listen`, `.notify`) without also depending on `@effect/sql-pg` itself.
|
|
35
|
+
export { PgClient } from '@effect/sql-pg';
|
|
36
|
+
|
|
37
|
+
/** Both stores, each built from an explicit URL. */
|
|
38
|
+
export const layer = (options: {
|
|
39
|
+
readonly postgres: PostgresOptions;
|
|
40
|
+
readonly valkey: ValkeyOptions;
|
|
41
|
+
}): Layer.Layer<
|
|
42
|
+
PgClient.PgClient | SqlClient.SqlClient | Redis.Redis,
|
|
43
|
+
SqlError | Redis.RedisError
|
|
44
|
+
> => Layer.mergeAll(postgres(options.postgres), valkey(options.valkey));
|
|
45
|
+
|
|
46
|
+
/** Both stores, each URL read from its environment variable (`SEAT_POSTGRES_URL`, `SEAT_VALKEY_URL`). */
|
|
47
|
+
export const layerFromEnv = (options?: {
|
|
48
|
+
readonly postgres?: PostgresFromEnvOptions | undefined;
|
|
49
|
+
readonly valkey?: ValkeyFromEnvOptions | undefined;
|
|
50
|
+
}): Layer.Layer<
|
|
51
|
+
PgClient.PgClient | SqlClient.SqlClient | Redis.Redis,
|
|
52
|
+
SqlError | Redis.RedisError | Config.ConfigError
|
|
53
|
+
> => Layer.mergeAll(postgresFromEnv(options?.postgres), valkeyFromEnv(options?.valkey));
|
package/src/stamp.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-request marks a seat puts on every LiteLLM call, applied as an `HttpClient`
|
|
3
|
+
* transform because `@effect/ai-openai-compat` gives no other place to set them.
|
|
4
|
+
*
|
|
5
|
+
* ★ WHY A TRANSFORM AND NOT MODEL CONFIG. The compat mapper forwards unknown config keys
|
|
6
|
+
* into the body but DROPS `metadata` (it is a known Responses-API field with no
|
|
7
|
+
* chat-completions place), and no config key can set a header. Measured in
|
|
8
|
+
* packages/seat-runtime/tests/stamp.test.ts against the installed rc.115.
|
|
9
|
+
* ★ IT IS THE SAME TRANSFORM AS cf-harness (landscape PR 165, cf-harness/src/request.ts),
|
|
10
|
+
* which is where the header and the body fields were measured against LiteLLM.
|
|
11
|
+
*/
|
|
12
|
+
import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
|
|
13
|
+
|
|
14
|
+
export type SeatStamp = {
|
|
15
|
+
/** Already validated and joined: LiteLLM reads one comma-separated header. */
|
|
16
|
+
readonly tags: string;
|
|
17
|
+
readonly noCache: boolean;
|
|
18
|
+
readonly metadata: Readonly<Record<string, string>> | undefined;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* ⛔ A TAG IS A TOKEN, NOT A SENTENCE. LiteLLM splits `x-litellm-tags` on commas and this
|
|
23
|
+
* package joins with them, so a comma inside a tag would silently become two tags, and a
|
|
24
|
+
* control character in a header value is header injection. Printable ASCII, no comma, no
|
|
25
|
+
* space; `host:ct100` and `seat:cf-coding` are the shape.
|
|
26
|
+
*/
|
|
27
|
+
const TAG = /^[\x21-\x2b\x2d-\x7e]{1,128}$/;
|
|
28
|
+
|
|
29
|
+
/** Validate and join. Throws at layer construction, where the mistake is one line away. */
|
|
30
|
+
export function joinTags(tags: string | ReadonlyArray<string>): string {
|
|
31
|
+
const list = typeof tags === 'string' ? tags.split(',') : [...tags];
|
|
32
|
+
if (list.length === 0) throw new TypeError('seat tags: at least one tag is required');
|
|
33
|
+
for (const tag of list) {
|
|
34
|
+
if (!TAG.test(tag)) {
|
|
35
|
+
throw new TypeError(
|
|
36
|
+
`seat tags: ${JSON.stringify(tag)} is not a tag (1-128 printable ASCII, no comma or space)`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return list.join(',');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The JSON body as an object, or undefined for anything this must leave alone. */
|
|
44
|
+
function jsonObject(
|
|
45
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
46
|
+
): Record<string, unknown> | undefined {
|
|
47
|
+
const body = request.body;
|
|
48
|
+
if (body._tag !== 'Uint8Array' || !body.contentType.includes('json')) return undefined;
|
|
49
|
+
// ⚠️ `text` is only "the original text retained for adapters that can skip encoding"
|
|
50
|
+
// (HttpBody.d.ts): a body built from bytes has none. Decoding the bytes keeps the stamp
|
|
51
|
+
// from becoming a silent no-op, which is the failure that would leave the LiteLLM cache
|
|
52
|
+
// ON for a seat that asked for it off.
|
|
53
|
+
const text = body.text ?? new TextDecoder().decode(body.body);
|
|
54
|
+
let parsed: unknown;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(text);
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
|
|
61
|
+
return parsed as Record<string, unknown>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Headers and body fields for one request. Pure: the same request in, a new one out. */
|
|
65
|
+
export function stampRequest(
|
|
66
|
+
request: HttpClientRequest.HttpClientRequest,
|
|
67
|
+
stamp: SeatStamp,
|
|
68
|
+
): HttpClientRequest.HttpClientRequest {
|
|
69
|
+
const headers = stamp.noCache
|
|
70
|
+
? { 'x-litellm-tags': stamp.tags, 'cache-control': 'no-cache, no-store' }
|
|
71
|
+
: { 'x-litellm-tags': stamp.tags };
|
|
72
|
+
const tagged = HttpClientRequest.setHeaders(request, headers);
|
|
73
|
+
const body = jsonObject(request);
|
|
74
|
+
if (body === undefined) return tagged;
|
|
75
|
+
return HttpClientRequest.bodyJsonUnsafe(tagged, {
|
|
76
|
+
...body,
|
|
77
|
+
// ⚠️ THE HEADER ALONE DOES NOT TURN THE CACHE OFF. Measured 2026-09-25 (cf-review PR 22,
|
|
78
|
+
// LiteLLM 1.100.0): `cache: {"no-cache": true}` skips the read and `"no-store": true`
|
|
79
|
+
// skips the write; `caching: false` in the body is a no-op, and a repeat answered from
|
|
80
|
+
// the cache reads as agreement. The Cache-Control header is sent as well, for the
|
|
81
|
+
// Cloudflare AI Gateway below LiteLLM, and is not what LiteLLM honours.
|
|
82
|
+
...(stamp.noCache ? { cache: { 'no-cache': true, 'no-store': true } } : {}),
|
|
83
|
+
// ★ ZERO, ALWAYS. A seat retries in Effect, where the attempt is a span; LiteLLM retrying
|
|
84
|
+
// behind it would multiply attempts invisibly (and bill each one).
|
|
85
|
+
num_retries: 0,
|
|
86
|
+
...(stamp.metadata !== undefined && !('metadata' in body) ? { metadata: stamp.metadata } : {}),
|
|
87
|
+
});
|
|
88
|
+
}
|
package/src/state-dsn.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a connection string without ever printing it.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ A DSN CARRIES A PASSWORD, so nothing here returns, throws or formats the string itself: a
|
|
5
|
+
* caller gets back the few non-secret fields (host, port, database, user name) or `undefined`.
|
|
6
|
+
* 🔴 WHY THIS EXISTS AT ALL. Measured 2026-09-29 (`@effect/sql-pg` rc.115, Bun 1.4.0): a DSN that
|
|
7
|
+
* `new URL` cannot parse fails as `SqlError` -> `ConnectionError` whose `cause` is the URL
|
|
8
|
+
* `TypeError`, and that error's text carries the WHOLE string, password included. Three of the
|
|
9
|
+
* six bad DSNs tried leaked it through `JSON.stringify` and `Bun.inspect` alike. Parsing here
|
|
10
|
+
* first, with the same `new URL`, means a string that would leak never reaches the driver:
|
|
11
|
+
* what the driver still rejects (a wrong scheme, an `sslmode` it lacks) echoes only that one
|
|
12
|
+
* token, not the string.
|
|
13
|
+
* 🔴 THE SECOND REASON: `@effect/sql-pg` labels every query span from its discrete config fields
|
|
14
|
+
* and IGNORES the URL for that. A client built from `url` alone exports
|
|
15
|
+
* `server.address: localhost`, `server.port: 5432` and `db.namespace: postgres` for EVERY
|
|
16
|
+
* query whatever it is connected to (measured 2026-09-29 against CT100's Postgres through a
|
|
17
|
+
* tunnel), so a trace would point at the wrong database. `postgresFields` hands the driver the
|
|
18
|
+
* same host, port, database and user the URL names, decoded the way the driver decodes them.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The non-secret fields a Postgres URL names; a field the URL leaves out is absent. */
|
|
22
|
+
export type PostgresFields = {
|
|
23
|
+
readonly host?: string | undefined;
|
|
24
|
+
readonly port?: number | undefined;
|
|
25
|
+
readonly database?: string | undefined;
|
|
26
|
+
readonly username?: string | undefined;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** The non-secret fields a Valkey URL names; `port` defaults to 6379 for a TCP scheme. */
|
|
30
|
+
export type ValkeyFields = {
|
|
31
|
+
readonly host?: string | undefined;
|
|
32
|
+
readonly port?: number | undefined;
|
|
33
|
+
/** The logical database index (`redis://host/1`), when there is one. */
|
|
34
|
+
readonly database?: string | undefined;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Query keys that OVERRIDE the authority in `@effect/sql-pg`'s own parser (rc.115 `parseUrl`).
|
|
39
|
+
* ⚠️ When a URL carries one, the fields below would be a second, conflicting answer (explicit
|
|
40
|
+
* config wins over the URL), so none are derived and the driver's reading stands.
|
|
41
|
+
*/
|
|
42
|
+
const OVERRIDING_KEYS: ReadonlySet<string> = new Set(['host', 'port', 'user', 'dbname']);
|
|
43
|
+
|
|
44
|
+
/** `decodeURIComponent` that reports failure as `undefined` instead of throwing its message. */
|
|
45
|
+
function decode(value: string): string | undefined {
|
|
46
|
+
try {
|
|
47
|
+
return decodeURIComponent(value);
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `new URL` that reports failure as `undefined`: its `TypeError` carries the string. */
|
|
54
|
+
function parse(raw: string): URL | undefined {
|
|
55
|
+
try {
|
|
56
|
+
return new URL(raw);
|
|
57
|
+
} catch {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* `undefined` when `dsn` is not a URL at all (the one case the driver would leak on); otherwise
|
|
64
|
+
* the fields it names, or none when the URL is one the driver should read alone.
|
|
65
|
+
*/
|
|
66
|
+
export function postgresFields(dsn: string): PostgresFields | undefined {
|
|
67
|
+
const url = parse(dsn);
|
|
68
|
+
if (url === undefined) return undefined;
|
|
69
|
+
for (const key of url.searchParams.keys()) if (OVERRIDING_KEYS.has(key)) return {};
|
|
70
|
+
const hostname =
|
|
71
|
+
url.hostname.startsWith('[') && url.hostname.endsWith(']')
|
|
72
|
+
? url.hostname.slice(1, -1)
|
|
73
|
+
: decode(url.hostname);
|
|
74
|
+
const database = decode(url.pathname.replace(/^\//, ''));
|
|
75
|
+
const username = decode(url.username);
|
|
76
|
+
const port = url.port === '' ? undefined : Number(url.port);
|
|
77
|
+
// ⚠️ A piece that will not decode or parse is left to the driver, which fails with its own
|
|
78
|
+
// message (it names a port or a component, never the string).
|
|
79
|
+
if (hostname === undefined || database === undefined || username === undefined) return {};
|
|
80
|
+
if (port !== undefined && !(Number.isInteger(port) && port >= 1 && port <= 65535)) return {};
|
|
81
|
+
return {
|
|
82
|
+
host: hostname === '' ? undefined : hostname,
|
|
83
|
+
port,
|
|
84
|
+
database: database === '' ? undefined : database,
|
|
85
|
+
username: username === '' ? undefined : username,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The URL schemes `Bun.RedisClient` accepts (its own error names exactly these seven). */
|
|
90
|
+
const VALKEY_SCHEMES: ReadonlySet<string> = new Set([
|
|
91
|
+
'redis:',
|
|
92
|
+
'valkey:',
|
|
93
|
+
'rediss:',
|
|
94
|
+
'valkeys:',
|
|
95
|
+
'redis+tls:',
|
|
96
|
+
'redis+unix:',
|
|
97
|
+
'redis+tls+unix:',
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
/** `undefined` when `raw` is not a URL of a scheme `Bun.RedisClient` takes. */
|
|
101
|
+
export function valkeyFields(raw: string): ValkeyFields | undefined {
|
|
102
|
+
const url = parse(raw);
|
|
103
|
+
if (url === undefined || !VALKEY_SCHEMES.has(url.protocol)) return undefined;
|
|
104
|
+
if (url.protocol.endsWith('+unix:')) return {};
|
|
105
|
+
const host = url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname;
|
|
106
|
+
const database = url.pathname.replace(/^\//, '');
|
|
107
|
+
return {
|
|
108
|
+
host: host === '' ? undefined : host,
|
|
109
|
+
port: url.port === '' ? 6379 : Number(url.port),
|
|
110
|
+
database: database === '' ? undefined : database,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres as Effect's own `SqlClient`, through `@effect/sql-pg`.
|
|
3
|
+
*
|
|
4
|
+
* ★ THE SDK'S OWN LAYER, NOT A WRAPPER OF IT. `PgClient` opens the pool and creates one
|
|
5
|
+
* `sql.execute` client span per statement carrying `db.system.name: postgresql`,
|
|
6
|
+
* `db.namespace`, `server.address`, `server.port` and `db.query.text` (measured 2026-09-29
|
|
7
|
+
* against CT100's Postgres), so state lands in the seat's trace with no `withSpan` of ours.
|
|
8
|
+
* ⚠️ `db.query.text` is the statement with its `$1` placeholders: parameter VALUES are not in
|
|
9
|
+
* the span (tests/state-postgres.test.ts asserts a canary value never leaves).
|
|
10
|
+
* ⛔ WHAT THIS ADDS is the two things the driver leaves to the caller, both measured
|
|
11
|
+
* 2026-09-29 and both in state-dsn.ts: a DSN that will not parse never reaches the driver (its
|
|
12
|
+
* error carries the string, password included), and the URL's host, port, database and user are
|
|
13
|
+
* passed as discrete fields so the spans name the database that was actually reached (from `url`
|
|
14
|
+
* alone they say `localhost:5432`, database `postgres`).
|
|
15
|
+
* ⛔ BUILDING THE LAYER RUNS `select 1`. The pool is lazy (a bad host or password otherwise fails
|
|
16
|
+
* at the first query), so a seat that starts against a dead or refusing Postgres is told at
|
|
17
|
+
* startup, as `SqlError`, within `connectTimeout`.
|
|
18
|
+
* ⚠️ PORTABLE. `@effect/sql-pg` rc.115 speaks the wire protocol itself over `node:net`, with no
|
|
19
|
+
* driver package, so this half runs under Bun and Node alike (the Valkey half needs Bun).
|
|
20
|
+
*/
|
|
21
|
+
import { PgClient } from '@effect/sql-pg';
|
|
22
|
+
import * as Config from 'effect/Config';
|
|
23
|
+
import * as Duration from 'effect/Duration';
|
|
24
|
+
import * as Effect from 'effect/Effect';
|
|
25
|
+
import * as Layer from 'effect/Layer';
|
|
26
|
+
import * as Redacted from 'effect/Redacted';
|
|
27
|
+
import type * as SqlClient from 'effect/unstable/sql/SqlClient';
|
|
28
|
+
import { ConnectionError, SqlError } from 'effect/unstable/sql/SqlError';
|
|
29
|
+
import { postgresFields } from './state-dsn.ts';
|
|
30
|
+
|
|
31
|
+
export type PostgresOptions = {
|
|
32
|
+
/**
|
|
33
|
+
* `postgres://<user>:<password>@<host>:<port>/<database>` (or `postgresql://`), the user and
|
|
34
|
+
* password percent-encoded. Held `Redacted`; never in a span, log or error. Required: this
|
|
35
|
+
* package holds no host. `sslmode=require|verify-ca|verify-full|disable` is read; the driver
|
|
36
|
+
* refuses `prefer` and `allow`.
|
|
37
|
+
*/
|
|
38
|
+
readonly url: string | Redacted.Redacted<string>;
|
|
39
|
+
/** Pool ceiling; the driver's default when absent. */
|
|
40
|
+
readonly maxConnections?: number | undefined;
|
|
41
|
+
/** How long to wait for a connection, and for the startup `select 1`. Driver default 5 s. */
|
|
42
|
+
readonly connectTimeout?: Duration.Input | undefined;
|
|
43
|
+
/** Shown in `pg_stat_activity`; the driver's default when absent. */
|
|
44
|
+
readonly applicationName?: string | undefined;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The environment variable `postgresFromEnv` reads unless told another. Not a host. */
|
|
48
|
+
export const POSTGRES_URL_VARIABLE = 'SEAT_POSTGRES_URL';
|
|
49
|
+
|
|
50
|
+
const DEFAULT_CONNECT_TIMEOUT: Duration.Duration = Duration.seconds(5);
|
|
51
|
+
|
|
52
|
+
/** A failure that names the reason and never the DSN. */
|
|
53
|
+
const refused = (message: string): SqlError =>
|
|
54
|
+
new SqlError({
|
|
55
|
+
reason: new ConnectionError({
|
|
56
|
+
cause: new Error(message),
|
|
57
|
+
message: `SeatState postgres: ${message}`,
|
|
58
|
+
operation: 'connect',
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const acquire = Effect.fnUntraced(function* (options: PostgresOptions) {
|
|
63
|
+
const dsn = Redacted.value(
|
|
64
|
+
typeof options.url === 'string' ? Redacted.make(options.url) : options.url,
|
|
65
|
+
);
|
|
66
|
+
const fields = postgresFields(dsn);
|
|
67
|
+
if (fields === undefined) return yield* refused('the URL is not a postgres:// URL');
|
|
68
|
+
const connectTimeout = options.connectTimeout ?? DEFAULT_CONNECT_TIMEOUT;
|
|
69
|
+
const client = yield* PgClient.make({
|
|
70
|
+
url: Redacted.make(dsn),
|
|
71
|
+
...fields,
|
|
72
|
+
maxConnections: options.maxConnections,
|
|
73
|
+
connectTimeout,
|
|
74
|
+
applicationName: options.applicationName,
|
|
75
|
+
});
|
|
76
|
+
yield* client`select 1`.pipe(
|
|
77
|
+
Effect.timeoutOrElse({
|
|
78
|
+
duration: connectTimeout,
|
|
79
|
+
orElse: () =>
|
|
80
|
+
Effect.fail(
|
|
81
|
+
refused(`did not answer within ${String(Duration.toMillis(connectTimeout))} ms`),
|
|
82
|
+
),
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
return client;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `SqlClient` and `PgClient` against one Postgres. The pool closes with the layer's scope.
|
|
90
|
+
*/
|
|
91
|
+
export const postgres = (
|
|
92
|
+
options: PostgresOptions,
|
|
93
|
+
): Layer.Layer<PgClient.PgClient | SqlClient.SqlClient, SqlError> =>
|
|
94
|
+
PgClient.layerFrom(Effect.suspend(() => acquire(options)));
|
|
95
|
+
|
|
96
|
+
export type PostgresFromEnvOptions = Omit<PostgresOptions, 'url'> & {
|
|
97
|
+
/** The variable holding the URL. Default `SEAT_POSTGRES_URL`. */
|
|
98
|
+
readonly variable?: string | undefined;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* `postgres`, its URL read from an environment variable as a `Redacted` secret. A missing
|
|
103
|
+
* variable fails as `ConfigError`, naming the variable and nothing else.
|
|
104
|
+
*/
|
|
105
|
+
export const postgresFromEnv = (
|
|
106
|
+
options?: PostgresFromEnvOptions,
|
|
107
|
+
): Layer.Layer<PgClient.PgClient | SqlClient.SqlClient, SqlError | Config.ConfigError> => {
|
|
108
|
+
const { variable, ...rest } = options ?? {};
|
|
109
|
+
return Layer.unwrap(
|
|
110
|
+
Config.Redacted(variable ?? POSTGRES_URL_VARIABLE).pipe(
|
|
111
|
+
Effect.map((url) => postgres({ ...rest, url })),
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
};
|