@wacry/codeks-bridge 0.0.0-stage → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +13 -0
  3. package/README.md +40 -2
  4. package/catalog/README.md +63 -0
  5. package/catalog/claude-opus-5-5.json +194 -0
  6. package/catalog/claude-sonnet-5-5.json +194 -0
  7. package/catalog/deepseek-flash.json +203 -0
  8. package/catalog/gpt-5.6-sol.json +152 -0
  9. package/catalog/gpt-6-astra.json +184 -0
  10. package/catalog/gpt-6-luna.json +178 -0
  11. package/catalog/gpt-6-sol.json +182 -0
  12. package/catalog/gpt-6.1-sol.json +187 -0
  13. package/dist/adapter-options.d.ts +5 -0
  14. package/dist/adapter-options.js +41 -0
  15. package/dist/auth.d.ts +2 -0
  16. package/dist/auth.js +42 -0
  17. package/dist/backends/anthropic-tools.d.ts +13 -0
  18. package/dist/backends/anthropic-tools.js +41 -0
  19. package/dist/backends/anthropic.d.ts +9 -0
  20. package/dist/backends/anthropic.js +356 -0
  21. package/dist/backends/chat.d.ts +16 -0
  22. package/dist/backends/chat.js +284 -0
  23. package/dist/backends/deepseek.d.ts +6 -0
  24. package/dist/backends/deepseek.js +37 -0
  25. package/dist/backends/responses.d.ts +5 -0
  26. package/dist/backends/responses.js +227 -0
  27. package/dist/carrier.d.ts +22 -0
  28. package/dist/carrier.js +61 -0
  29. package/dist/catalog.d.ts +8 -0
  30. package/dist/catalog.js +40 -0
  31. package/dist/cli.d.ts +2 -0
  32. package/dist/cli.js +64 -0
  33. package/dist/compaction-handoff.d.ts +13 -0
  34. package/dist/compaction-handoff.js +126 -0
  35. package/dist/compaction-history.d.ts +7 -0
  36. package/dist/compaction-history.js +20 -0
  37. package/dist/compaction.d.ts +20 -0
  38. package/dist/compaction.js +134 -0
  39. package/dist/config.d.ts +6 -0
  40. package/dist/config.js +170 -0
  41. package/dist/errors.d.ts +43 -0
  42. package/dist/errors.js +141 -0
  43. package/dist/headers.d.ts +6 -0
  44. package/dist/headers.js +39 -0
  45. package/dist/history.d.ts +7 -0
  46. package/dist/history.js +85 -0
  47. package/dist/ids.d.ts +1 -0
  48. package/dist/ids.js +4 -0
  49. package/dist/index.d.ts +5 -0
  50. package/dist/index.js +4 -0
  51. package/dist/inspect/capture.d.ts +119 -0
  52. package/dist/inspect/capture.js +370 -0
  53. package/dist/inspect/http.d.ts +7 -0
  54. package/dist/inspect/http.js +149 -0
  55. package/dist/inspect/view.d.ts +239 -0
  56. package/dist/inspect/view.js +388 -0
  57. package/dist/model-catalog.d.ts +18 -0
  58. package/dist/model-catalog.js +16 -0
  59. package/dist/profiles.d.ts +6 -0
  60. package/dist/profiles.js +63 -0
  61. package/dist/prompts.d.ts +5 -0
  62. package/dist/prompts.js +118 -0
  63. package/dist/server.d.ts +4 -0
  64. package/dist/server.js +232 -0
  65. package/dist/sse.d.ts +12 -0
  66. package/dist/sse.js +93 -0
  67. package/dist/tool-history.d.ts +4 -0
  68. package/dist/tool-history.js +73 -0
  69. package/dist/tools.d.ts +35 -0
  70. package/dist/tools.js +193 -0
  71. package/dist/types.d.ts +125 -0
  72. package/dist/types.js +1 -0
  73. package/dist/upstream.d.ts +3 -0
  74. package/dist/upstream.js +89 -0
  75. package/dist/writer.d.ts +95 -0
  76. package/dist/writer.js +430 -0
  77. package/docs/backends-anthropic.md +88 -0
  78. package/docs/backends-deepseek-chat.md +75 -0
  79. package/docs/backends-responses.md +33 -0
  80. package/docs/compaction.md +180 -0
  81. package/docs/config.md +271 -0
  82. package/docs/design.md +97 -0
  83. package/docs/inspect.md +148 -0
  84. package/docs/reasoning.md +72 -0
  85. package/docs/releasing.md +50 -0
  86. package/docs/responses-stream.md +90 -0
  87. package/docs/testing.md +177 -0
  88. package/docs/tools.md +68 -0
  89. package/examples/config.json +35 -0
  90. package/examples/gpt-claude.json +27 -0
  91. package/package.json +46 -4
  92. package/ui/api.js +64 -0
  93. package/ui/app.css +379 -0
  94. package/ui/app.js +489 -0
  95. package/ui/detail.js +713 -0
  96. package/ui/diff.js +281 -0
  97. package/ui/dom.js +258 -0
  98. package/ui/events.js +376 -0
  99. package/ui/favicon.svg +1 -0
  100. package/ui/index.html +58 -0
  101. package/ui/labels.js +106 -0
  102. package/ui/list.js +180 -0
  103. package/ui/structure.js +285 -0
  104. package/ui/tree.js +158 -0
@@ -0,0 +1,40 @@
1
+ import { modelProfile } from "./profiles.js";
2
+ import { BridgeError } from "./errors.js";
3
+ /** Codex bounds downloads from an explicit model_catalog_url to 1 MiB. */
4
+ export const MAX_HTTP_CATALOG_BYTES = 1024 * 1024;
5
+ function exposedModels(config) {
6
+ return Object.entries(config.models).filter(([, model]) => model.expose !== false);
7
+ }
8
+ /** Serve client-exposed aliases, using their own reviewed client behavior. */
9
+ export function buildCatalog(config) {
10
+ return { models: exposedModels(config).map(([slug, model], priority) => ({
11
+ ...modelProfile(model).model_info,
12
+ slug, display_name: slug, priority, visibility: "list", supported_in_api: true,
13
+ })) };
14
+ }
15
+ export function configSnippet(config, catalogPath) {
16
+ const first = exposedModels(config)[0]?.[0];
17
+ if (first === undefined)
18
+ throw new BridgeError("config-snippet requires at least one model with expose enabled");
19
+ const host = ["0.0.0.0", "::"].includes(config.host ?? "") ? "127.0.0.1" : config.host ?? "127.0.0.1";
20
+ const authority = host.includes(":") ? `[${host}]` : host;
21
+ const baseUrl = `http://${authority}:${config.port ?? 8787}/v1`;
22
+ const lines = [
23
+ `model = ${JSON.stringify(first)}`, 'model_provider = "bridge"',
24
+ ...(catalogPath ? [`model_catalog_json = ${JSON.stringify(catalogPath)}`] : []),
25
+ 'web_search = "disabled"', "", "[features]", "image_generation = false", "",
26
+ // Outside the reserved "collaboration" namespace, codeks sends multi-agent task messages as plaintext.
27
+ "[features.multi_agent_v2]", 'tool_namespace = "agents"', "",
28
+ "[model_providers.bridge]", 'name = "Local bridge"',
29
+ `base_url = ${JSON.stringify(baseUrl)}`, 'wire_api = "responses"',
30
+ ];
31
+ if (!catalogPath)
32
+ lines.push(`model_catalog_url = ${JSON.stringify(`${baseUrl}/models`)}`);
33
+ if (config.token_env)
34
+ lines.push(`env_key = ${JSON.stringify(config.token_env)}`);
35
+ // Codex requires API-key auth mode for provider catalog discovery, even on loopback.
36
+ else if (!catalogPath)
37
+ lines.push('experimental_bearer_token = "codeks-bridge-local"');
38
+ lines.push("", "[model_providers.bridge.capabilities]", 'remote_compaction = "v2"', "");
39
+ return lines.join("\n");
40
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { writeFile } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import { loadConfig } from "./config.js";
6
+ import { createBridgeServer } from "./server.js";
7
+ import { buildCatalog, configSnippet } from "./catalog.js";
8
+ const HELP = `Usage: codeks-bridge <command> --config <file> [options]
9
+
10
+ Commands:
11
+ serve Start the local Responses API server
12
+ catalog Export the client-visible model catalog for codeks
13
+ config-snippet Print the codeks provider configuration
14
+
15
+ Options:
16
+ --output <file> Write catalog JSON (otherwise stdout)
17
+ --catalog <file> Use a static catalog instead of HTTP discovery
18
+ --capture Start serve with inspector capture on
19
+ --help Show this help
20
+ `;
21
+ async function main() {
22
+ const { values, positionals } = parseArgs({ allowPositionals: true, options: {
23
+ config: { type: "string" }, output: { type: "string" },
24
+ catalog: { type: "string" }, capture: { type: "boolean" }, help: { type: "boolean" },
25
+ } });
26
+ if (values.help || positionals.length === 0) {
27
+ process.stdout.write(HELP);
28
+ return;
29
+ }
30
+ const command = positionals[0];
31
+ if (positionals.length !== 1 || !["serve", "catalog", "config-snippet"].includes(command))
32
+ throw new Error("Unknown command. Use --help.");
33
+ if (!values.config)
34
+ throw new Error("--config is required");
35
+ const config = await loadConfig(values.config);
36
+ if (command === "catalog") {
37
+ const text = JSON.stringify(buildCatalog(config), null, 2) + "\n";
38
+ if (values.output)
39
+ await writeFile(values.output, text, { mode: 0o600 });
40
+ else
41
+ process.stdout.write(text);
42
+ }
43
+ else if (command === "config-snippet") {
44
+ process.stdout.write(configSnippet(config, values.catalog ? resolve(values.catalog) : undefined));
45
+ }
46
+ else {
47
+ if (values.capture)
48
+ config.inspect = { ...config.inspect, capture: true };
49
+ const server = createBridgeServer(config);
50
+ server.on("error", error => { process.stderr.write(`Server error: ${error.message}\n`); process.exitCode = 1; });
51
+ server.listen(config.port ?? 8787, config.host ?? "127.0.0.1", () => {
52
+ const address = server.address();
53
+ const port = typeof address === "object" ? address?.port : config.port;
54
+ const host = config.host ?? "127.0.0.1";
55
+ const shown = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host.includes(":") ? `[${host}]` : host;
56
+ process.stderr.write(`Listening on ${host}:${port}\n`);
57
+ process.stderr.write(`Inspector: http://${shown}:${port}/inspect/ (capture ${config.inspect?.capture ? "on" : "off"})\n`);
58
+ });
59
+ const stop = () => { server.close(); server.closeAllConnections(); };
60
+ process.once("SIGINT", stop);
61
+ process.once("SIGTERM", stop);
62
+ }
63
+ }
64
+ main().catch(error => { process.stderr.write(`${error instanceof Error ? error.message : "Command failed"}\n`); process.exitCode = 1; });
@@ -0,0 +1,13 @@
1
+ import type { AdapterId, Backend, BackendContext, BridgeConfig, ResponsesRequest } from "./types.ts";
2
+ /** Successful exports survive restarts; each caller owns only its own cancellation. */
3
+ export declare class CompactionHandoffStore {
4
+ readonly directory: string;
5
+ private readonly config;
6
+ private readonly backends;
7
+ private readonly pending;
8
+ constructor(config: BridgeConfig, backends: Record<AdapterId, Backend>);
9
+ private source;
10
+ private summary;
11
+ /** Only checkpoint predecessors enter the export; later work stays on the target. */
12
+ materialize(context: BackendContext): Promise<ResponsesRequest>;
13
+ }
@@ -0,0 +1,126 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { decodeCompaction, encodeCompaction } from "./carrier.js";
6
+ import { collectHandoff, handoffRequest } from "./compaction.js";
7
+ import { splitCompactionPrefix } from "./compaction-history.js";
8
+ import { resolveModel } from "./config.js";
9
+ import { BridgeError } from "./errors.js";
10
+ import { restoresNativeCompaction } from "./history.js";
11
+ function waitForTurn(previous, signal) {
12
+ signal.throwIfAborted();
13
+ return new Promise((resolve, reject) => {
14
+ const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason); };
15
+ signal.addEventListener("abort", abort, { once: true });
16
+ previous.then(() => {
17
+ signal.removeEventListener("abort", abort);
18
+ if (signal.aborted)
19
+ reject(signal.reason);
20
+ else
21
+ resolve();
22
+ });
23
+ });
24
+ }
25
+ /** Successful exports survive restarts; each caller owns only its own cancellation. */
26
+ export class CompactionHandoffStore {
27
+ directory;
28
+ config;
29
+ backends;
30
+ pending = new Map();
31
+ constructor(config, backends) {
32
+ this.config = config;
33
+ this.backends = backends;
34
+ this.directory = config.compaction_cache_dir ?? join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "codeks-bridge", "compaction");
35
+ }
36
+ source(carrier) {
37
+ const preferred = carrier.handoff.parameters.model;
38
+ const aliases = [...new Set([preferred, ...Object.keys(this.config.models)])];
39
+ for (const alias of aliases) {
40
+ if (!Object.hasOwn(this.config.models, alias))
41
+ continue;
42
+ const route = resolveModel(this.config, alias);
43
+ if (route.origin.id === carrier.native.source.id)
44
+ return route;
45
+ }
46
+ throw new BridgeError("The original compaction source is not configured; restore its model and upstream to generate a handoff.");
47
+ }
48
+ async summary(carrier, request, context) {
49
+ const { signal } = context;
50
+ const key = createHash("sha256").update(JSON.stringify({
51
+ source: carrier.native.source,
52
+ request,
53
+ responsesLite: carrier.handoff.responsesLite,
54
+ })).digest("hex");
55
+ const previous = this.pending.get(key) ?? Promise.resolve();
56
+ let release;
57
+ const gate = new Promise(resolve => { release = resolve; });
58
+ const tail = previous.then(() => gate);
59
+ this.pending.set(key, tail);
60
+ void tail.then(() => { if (this.pending.get(key) === tail)
61
+ this.pending.delete(key); });
62
+ try {
63
+ await waitForTurn(previous, signal);
64
+ const path = join(this.directory, `${key}.json`);
65
+ try {
66
+ const cached = JSON.parse(await readFile(path, "utf8"));
67
+ if (typeof cached.summary !== "string" || !cached.summary.trim())
68
+ throw new BridgeError("Invalid cached compaction handoff.");
69
+ signal.throwIfAborted();
70
+ return cached.summary;
71
+ }
72
+ catch (error) {
73
+ if (error.code !== "ENOENT")
74
+ throw error;
75
+ }
76
+ signal.throwIfAborted();
77
+ const route = this.source(carrier);
78
+ const result = await collectHandoff({
79
+ request: { ...request, model: route.slug }, route, signal,
80
+ requestHeaders: context.requestHeaders,
81
+ responsesLite: carrier.handoff.responsesLite,
82
+ }, this.backends[route.profile.id]);
83
+ signal.throwIfAborted();
84
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
85
+ const temporary = join(this.directory, `.${key}.${randomUUID()}.tmp`);
86
+ try {
87
+ // Usage belongs to this export, never to the destination model's context.
88
+ await writeFile(temporary, JSON.stringify(result), { mode: 0o600, flag: "wx" });
89
+ await rename(temporary, path);
90
+ }
91
+ finally {
92
+ await unlink(temporary).catch((error) => { if (error.code !== "ENOENT")
93
+ throw error; });
94
+ }
95
+ signal.throwIfAborted();
96
+ return result.summary;
97
+ }
98
+ finally {
99
+ release();
100
+ }
101
+ }
102
+ /** Only checkpoint predecessors enter the export; later work stays on the target. */
103
+ async materialize(context) {
104
+ if (!Array.isArray(context.request.input))
105
+ return context.request;
106
+ let changed = false;
107
+ const input = context.request.input;
108
+ const prepared = [...input];
109
+ for (let i = 0; i < input.length; i++) {
110
+ const item = input[i];
111
+ if (item.type !== "compaction" && item.type !== "compaction_summary")
112
+ continue;
113
+ const carrier = decodeCompaction(item.encrypted_content);
114
+ if (!carrier || carrier.summary !== undefined || restoresNativeCompaction(carrier, context.route))
115
+ continue;
116
+ if (!carrier.native || !carrier.handoff)
117
+ throw new BridgeError("This compaction checkpoint has no handoff recipe.");
118
+ const { history } = splitCompactionPrefix(input.slice(0, i));
119
+ const request = handoffRequest(carrier.handoff, history, item);
120
+ const summary = await this.summary(carrier, request, context);
121
+ prepared[i] = { ...item, encrypted_content: encodeCompaction({ ...carrier, summary }) };
122
+ changed = true;
123
+ }
124
+ return changed ? { ...context.request, input: prepared } : context.request;
125
+ }
126
+ }
@@ -0,0 +1,7 @@
1
+ type Item = Record<string, any>;
2
+ /** Save source instructions and Lite declarations, without saving old conversation turns. */
3
+ export declare function splitCompactionPrefix(input: readonly Item[]): {
4
+ prefix: Item[];
5
+ history: Item[];
6
+ };
7
+ export {};
@@ -0,0 +1,20 @@
1
+ const isMessage = (item) => item.type === "message" || (!item.type && typeof item.role === "string");
2
+ /** Save source instructions and Lite declarations, without saving old conversation turns. */
3
+ export function splitCompactionPrefix(input) {
4
+ const prefix = [];
5
+ const history = [];
6
+ let leading = true;
7
+ for (const item of input) {
8
+ if (item.type === "additional_tools") {
9
+ prefix.push(item);
10
+ }
11
+ else if (leading && isMessage(item) && ["system", "developer"].includes(item.role)) {
12
+ prefix.push(item);
13
+ }
14
+ else {
15
+ leading = false;
16
+ history.push(item);
17
+ }
18
+ }
19
+ return { prefix, history };
20
+ }
@@ -0,0 +1,20 @@
1
+ import type { HandoffRecipe } from "./carrier.ts";
2
+ import type { Backend, BackendContext, ResponsesRequest } from "./types.ts";
3
+ import type { Usage } from "./writer.ts";
4
+ type Item = Record<string, any>;
5
+ export interface CompactionResult {
6
+ item: Record<string, unknown>;
7
+ usage: Usage;
8
+ }
9
+ export declare function isCompactionRequest(request: ResponsesRequest): boolean;
10
+ /** Keep the shared prefix intact, including tools and any signed reasoning. */
11
+ export declare function summaryRequest(request: ResponsesRequest): ResponsesRequest;
12
+ /** Rephrase the checkpoint using the exact history retained by the client. */
13
+ export declare function handoffRequest(recipe: HandoffRecipe, retained: Item[], checkpoint: Item): ResponsesRequest;
14
+ export declare function collectHandoff(context: BackendContext, backend: Backend): Promise<{
15
+ summary: string;
16
+ usage: Usage;
17
+ }>;
18
+ /** Publish a complete native checkpoint or portable text summary. */
19
+ export declare function compact(context: BackendContext, backend: Backend): Promise<CompactionResult>;
20
+ export {};
@@ -0,0 +1,134 @@
1
+ import { encodeCompaction } from "./carrier.js";
2
+ import { splitCompactionPrefix } from "./compaction-history.js";
3
+ import { BridgeError, ResponsesIncompleteError, ResponsesStreamError } from "./errors.js";
4
+ import { newId } from "./ids.js";
5
+ import { withCaptureLabel } from "./inspect/capture.js";
6
+ import { HANDOFF_PROMPT, SUMMARIZATION_PROMPT } from "./prompts.js";
7
+ import { ResponseWriter } from "./writer.js";
8
+ export function isCompactionRequest(request) {
9
+ return Array.isArray(request.input) && request.input.some((item) => item.type === "compaction_trigger");
10
+ }
11
+ function compactionInput(request) {
12
+ if (!Array.isArray(request.input))
13
+ throw new BridgeError("Compaction requires an input array.");
14
+ const triggers = request.input.filter((item) => item.type === "compaction_trigger");
15
+ if (triggers.length !== 1 || request.input.at(-1)?.type !== "compaction_trigger") {
16
+ throw new BridgeError("Compaction requires exactly one trigger at the end of input.");
17
+ }
18
+ return request.input.slice(0, -1);
19
+ }
20
+ /** Keep the shared prefix intact, including tools and any signed reasoning. */
21
+ export function summaryRequest(request) {
22
+ const input = compactionInput(request);
23
+ const result = {
24
+ ...request,
25
+ input: [...input, { type: "message", role: "user", content: [{ type: "input_text", text: SUMMARIZATION_PROMPT }] }],
26
+ stream: true,
27
+ };
28
+ // A conversation's structured output schema does not describe a handoff summary.
29
+ delete result.text;
30
+ return result;
31
+ }
32
+ function handoffRecipe(context, prefix) {
33
+ // The handoff rebuilds input and transport mode; a summary is not a structured answer.
34
+ const { input: _input, stream: _stream, text: _text, ...parameters } = context.request;
35
+ parameters.reasoning = { ...parameters.reasoning, effort: parameters.reasoning?.effort ?? context.route.profile.default_effort };
36
+ return structuredClone({ prefix, parameters, responsesLite: context.responsesLite === true });
37
+ }
38
+ /** Rephrase the checkpoint using the exact history retained by the client. */
39
+ export function handoffRequest(recipe, retained, checkpoint) {
40
+ return {
41
+ ...recipe.parameters,
42
+ input: [...recipe.prefix, ...retained, checkpoint,
43
+ { type: "message", role: "user", content: [{ type: "input_text", text: HANDOFF_PROMPT }] }],
44
+ stream: true,
45
+ };
46
+ }
47
+ async function collect(context, backend) {
48
+ const writer = new ResponseWriter({ write() { } }, { model: context.request.model, showSummary: false });
49
+ let result;
50
+ try {
51
+ result = await backend(context, writer);
52
+ }
53
+ catch (cause) {
54
+ // Auxiliary output and usage must not become the client's replacement history.
55
+ if (cause instanceof ResponsesIncompleteError) {
56
+ throw new BridgeError(cause.message, "invalid_prompt", 400, undefined, { upstream: { body: cause.response }, cause });
57
+ }
58
+ if (cause instanceof ResponsesStreamError && cause.response) {
59
+ const { output: _output, usage: _usage, ...response } = cause.response;
60
+ throw new ResponsesStreamError(cause.error, { ...response, output: [] }, cause.event, cause.headers);
61
+ }
62
+ throw cause;
63
+ }
64
+ context.signal.throwIfAborted();
65
+ writer.complete(result.usage);
66
+ return { output: writer.outputItems, usage: result.usage, endTurn: result.endTurn };
67
+ }
68
+ function summaryText({ output, endTurn }) {
69
+ const parts = [];
70
+ for (const value of output) {
71
+ const item = value;
72
+ if (item.type === "function_call" || item.type === "custom_tool_call") {
73
+ throw new BridgeError("Compaction returned a tool call instead of a summary. No tools were executed.");
74
+ }
75
+ if (item.type !== "message" || item.role !== "assistant" || !Array.isArray(item.content))
76
+ continue;
77
+ for (const part of item.content) {
78
+ if (part.type === "refusal")
79
+ throw new BridgeError("Compaction was refused.");
80
+ if (part.type === "output_text" && typeof part.text === "string")
81
+ parts.push(part.text);
82
+ }
83
+ }
84
+ if (endTurn === false)
85
+ throw new BridgeError("Compaction did not finish its response.");
86
+ const summary = parts.join("\n").trim();
87
+ if (!summary)
88
+ throw new BridgeError("Compaction returned an empty summary.");
89
+ return summary;
90
+ }
91
+ export async function collectHandoff(context, backend) {
92
+ // Generating text from the source's own native state is independent of whether
93
+ // that route normally chooses to replay native checkpoints in ordinary turns.
94
+ const route = { ...context.route, compaction: { ...context.route.compaction, native_replay: true } };
95
+ const result = await withCaptureLabel("handoff", () => collect({ ...context, route, compacting: true }, backend));
96
+ return { summary: summaryText(result), usage: result.usage };
97
+ }
98
+ function nativeItem(output) {
99
+ const items = output.filter((value) => {
100
+ const item = value;
101
+ return item.type === "compaction" || item.type === "compaction_summary";
102
+ });
103
+ if (items.length !== 1 || typeof items[0]?.encrypted_content !== "string" || !items[0].encrypted_content) {
104
+ throw new BridgeError("Native compaction must return exactly one nonempty compaction item.");
105
+ }
106
+ return items[0];
107
+ }
108
+ /** Publish a complete native checkpoint or portable text summary. */
109
+ export async function compact(context, backend) {
110
+ const input = compactionInput(context.request);
111
+ context.signal.throwIfAborted();
112
+ const item = (carrier) => ({
113
+ type: "compaction", id: newId("cmp"), encrypted_content: encodeCompaction(carrier),
114
+ });
115
+ if (context.route.compaction.mode === "summary") {
116
+ const result = await withCaptureLabel("summary", () => collect({
117
+ ...context, compacting: true, request: summaryRequest(context.request),
118
+ }, backend));
119
+ return { item: item({ summary: summaryText(result) }), usage: result.usage };
120
+ }
121
+ const native = await withCaptureLabel("native", () => collect({
122
+ ...context, compacting: true, request: { ...context.request, stream: true },
123
+ }, backend));
124
+ if (native.endTurn === false)
125
+ throw new BridgeError("Compaction did not finish its response.");
126
+ const { prefix } = splitCompactionPrefix(input);
127
+ return {
128
+ item: item({
129
+ native: { source: context.route.origin, format: context.route.profile.native_compaction_format, data: nativeItem(native.output) },
130
+ handoff: handoffRecipe(context, prefix),
131
+ }),
132
+ usage: native.usage,
133
+ };
134
+ }
@@ -0,0 +1,6 @@
1
+ import type { BridgeConfig, ResolvedModel } from "./types.ts";
2
+ export declare function validateConfig(value: unknown): BridgeConfig;
3
+ export declare function loadConfig(path: string): Promise<BridgeConfig>;
4
+ export declare function resolveModel(config: BridgeConfig, slug: string): ResolvedModel;
5
+ /** Select one executor; the caller owns the generation mode, the executor owns replay. */
6
+ export declare function resolveCompactionModel(config: BridgeConfig, caller: ResolvedModel): ResolvedModel;
package/dist/config.js ADDED
@@ -0,0 +1,170 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import { modelProfile } from "./profiles.js";
4
+ import { getCatalogEntry } from "./model-catalog.js";
5
+ import { BridgeError } from "./errors.js";
6
+ import { validateAdapterConfig } from "./adapter-options.js";
7
+ function object(value) {
8
+ return !!value && typeof value === "object" && !Array.isArray(value);
9
+ }
10
+ function fail(message) { throw new BridgeError(`Invalid configuration: ${message}`); }
11
+ function strings(value) {
12
+ return Array.isArray(value) && value.every(v => typeof v === "string" && v.length > 0);
13
+ }
14
+ const hash = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
15
+ /** Record provenance, never policy or credentials, in portable history. */
16
+ function origin(config, model) {
17
+ const connection = hash([model.upstream, config.upstreams[model.upstream].base_url.replace(/\/+$/, "")]);
18
+ const { adapter } = getCatalogEntry(model.catalog);
19
+ return { connection, model: model.model, adapter, id: hash([connection, adapter, model.model]) };
20
+ }
21
+ export function validateConfig(value) {
22
+ if (!object(value) || !object(value.upstreams) || !object(value.models))
23
+ fail("upstreams and models must be objects");
24
+ if (!Object.keys(value.models).length)
25
+ fail("at least one model is required");
26
+ if (value.host !== undefined && (typeof value.host !== "string" || !value.host))
27
+ fail("host must be a string");
28
+ if (value.port !== undefined && (!Number.isInteger(value.port) || value.port < 0 || value.port > 65535))
29
+ fail("port must be 0..65535");
30
+ if (value.token_env !== undefined && (typeof value.token_env !== "string" || !value.token_env))
31
+ fail("token_env must name an environment variable");
32
+ if (value.compaction_cache_dir !== undefined && (typeof value.compaction_cache_dir !== "string" || !value.compaction_cache_dir.trim()))
33
+ fail("compaction_cache_dir must be a nonempty path");
34
+ if (value.inspect !== undefined) {
35
+ const inspect = value.inspect;
36
+ if (!object(inspect))
37
+ fail("inspect must be an object");
38
+ if (inspect.capture !== undefined && typeof inspect.capture !== "boolean")
39
+ fail("inspect.capture must be boolean");
40
+ if (inspect.max_mib !== undefined && (!Number.isSafeInteger(inspect.max_mib) || inspect.max_mib <= 0))
41
+ fail("inspect.max_mib must be a positive integer");
42
+ }
43
+ for (const [name, up] of Object.entries(value.upstreams)) {
44
+ if (!object(up))
45
+ fail(`upstream ${name}: expected a connection object`);
46
+ let url;
47
+ try {
48
+ url = new URL(up.base_url);
49
+ }
50
+ catch {
51
+ fail(`upstream ${name}: invalid base_url`);
52
+ }
53
+ if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash)
54
+ fail(`upstream ${name}: base_url must be an HTTP(S) URL without credentials, query, or fragment`);
55
+ if (up.api_key_env !== undefined && (typeof up.api_key_env !== "string" || !up.api_key_env))
56
+ fail(`upstream ${name}: invalid api_key_env`);
57
+ if (up.api_key_env && up.api_key_command)
58
+ fail(`upstream ${name}: choose api_key_env or api_key_command`);
59
+ if (up.api_key_command !== undefined && (!Array.isArray(up.api_key_command) || !up.api_key_command.length || up.api_key_command.some((arg) => typeof arg !== "string")))
60
+ fail(`upstream ${name}: api_key_command must be a nonempty argv array`);
61
+ if (up.stream_idle_timeout_ms !== undefined && (!Number.isSafeInteger(up.stream_idle_timeout_ms) || up.stream_idle_timeout_ms <= 0))
62
+ fail(`upstream ${name}: invalid stream_idle_timeout_ms`);
63
+ if (up.auth_header !== undefined && (typeof up.auth_header !== "string" || !/^[\w-]+$/.test(up.auth_header)))
64
+ fail(`upstream ${name}: invalid auth_header`);
65
+ if (up.headers !== undefined && (!object(up.headers) || Object.values(up.headers).some(v => typeof v !== "string" || /[\r\n]/.test(v))))
66
+ fail(`upstream ${name}: invalid headers`);
67
+ if (up.options !== undefined && !object(up.options))
68
+ fail(`upstream ${name}: options must be an object`);
69
+ validateAdapterConfig(up.adapters, `upstream ${name}`);
70
+ }
71
+ for (const [slug, model] of Object.entries(value.models)) {
72
+ if (!slug || !object(model) || typeof model.model !== "string" || !model.model || typeof model.upstream !== "string" || !Object.hasOwn(value.upstreams, model.upstream))
73
+ fail(`model ${slug}: model and an existing upstream are required`);
74
+ if (typeof model.catalog !== "string" || !model.catalog)
75
+ fail(`model ${slug}: catalog is required`);
76
+ if (model.expose !== undefined && typeof model.expose !== "boolean")
77
+ fail(`model ${slug}: expose must be boolean`);
78
+ for (const field of ["context_window", "max_output_tokens"]) {
79
+ if (model[field] !== undefined && (!Number.isSafeInteger(model[field]) || model[field] <= 0))
80
+ fail(`model ${slug}: ${field} must be a positive integer`);
81
+ }
82
+ if (model.options !== undefined && !object(model.options))
83
+ fail(`model ${slug}: options must be an object`);
84
+ if (model.input_modalities !== undefined && (!strings(model.input_modalities) || !model.input_modalities.length || model.input_modalities.some((m) => !["text", "image"].includes(m))))
85
+ fail(`model ${slug}: input_modalities must contain text or image`);
86
+ if (model.reasoning !== undefined) {
87
+ const r = model.reasoning;
88
+ if (!object(r))
89
+ fail(`model ${slug}: reasoning must be an object`);
90
+ if (r.default_effort !== undefined && (typeof r.default_effort !== "string" || !r.default_effort))
91
+ fail(`model ${slug}: invalid default_effort`);
92
+ if (r.effort_map !== undefined && (!object(r.effort_map) || !Object.keys(r.effort_map).length || Object.entries(r.effort_map).some(([key, native]) => !key || typeof native !== "string" || !native)))
93
+ fail(`model ${slug}: effort_map must map effort names to upstream values`);
94
+ }
95
+ const profile = modelProfile(model);
96
+ validateAdapterConfig(model.adapters, `model ${slug}`, profile.id);
97
+ if (Object.hasOwn(profile.effort_map, "ultra") && !profile.multi_agent_effort)
98
+ fail(`model ${slug}: ultra must map to the same upstream effort as an ordinary level`);
99
+ if (!Object.hasOwn(profile.effort_map, profile.default_effort))
100
+ fail(`model ${slug}: default_effort must appear in effort_map`);
101
+ if (model.replay !== undefined) {
102
+ if (!object(model.replay))
103
+ fail(`model ${slug}: replay must be an object`);
104
+ for (const field of ["reasoning_from", "native_compaction_from"]) {
105
+ if (model.replay[field] !== undefined && !strings(model.replay[field]))
106
+ fail(`model ${slug}: ${field} must list source model aliases`);
107
+ }
108
+ }
109
+ if (model.compaction !== undefined) {
110
+ const c = model.compaction;
111
+ if (!object(c))
112
+ fail(`model ${slug}: compaction must be an object`);
113
+ if (c.mode !== undefined && !["summary", "native"].includes(c.mode))
114
+ fail(`model ${slug}: compaction.mode must be summary or native`);
115
+ if (c.model !== undefined && (typeof c.model !== "string" || !Object.hasOwn(value.models, c.model)))
116
+ fail(`model ${slug}: compaction.model must name an existing model alias`);
117
+ if (c.native_replay !== undefined && typeof c.native_replay !== "boolean")
118
+ fail(`model ${slug}: compaction.native_replay must be boolean`);
119
+ if (!profile.native_compaction_format && c.native_replay === true)
120
+ fail(`model ${slug}: adapter has no native compaction representation for replay`);
121
+ }
122
+ }
123
+ // References are directional. Validate decodability, not provider compatibility.
124
+ const config = value;
125
+ for (const [slug, model] of Object.entries(config.models)) {
126
+ const target = modelProfile(model);
127
+ for (const [field, format] of [["reasoning_from", "reasoning_format"], ["native_compaction_from", "native_compaction_format"]]) {
128
+ for (const name of model.replay?.[field] ?? []) {
129
+ if (!Object.hasOwn(config.models, name))
130
+ fail(`model ${slug}: unknown replay source ${name}`);
131
+ const source = modelProfile(config.models[name]);
132
+ if (!target[format] || source[format] !== target[format])
133
+ fail(`model ${slug}: cannot decode ${field} source ${name}`);
134
+ }
135
+ }
136
+ }
137
+ for (const slug of Object.keys(config.models)) {
138
+ const executor = resolveCompactionModel(config, resolveModel(config, slug));
139
+ if (executor.compaction.mode === "native" && !executor.profile.native_compaction_format)
140
+ fail(`model ${slug}: compaction model ${executor.slug} has no native compaction representation`);
141
+ }
142
+ return config;
143
+ }
144
+ export async function loadConfig(path) {
145
+ return validateConfig(JSON.parse(await readFile(path, "utf8")));
146
+ }
147
+ export function resolveModel(config, slug) {
148
+ if (typeof slug !== "string" || !Object.hasOwn(config.models, slug))
149
+ throw new BridgeError("Unknown model. Use GET /v1/models to list exposed models.");
150
+ const model = config.models[slug];
151
+ const profile = modelProfile(model);
152
+ const source = origin(config, model);
153
+ const allowed = (names = []) => new Set([source.id, ...names.map(name => origin(config, config.models[name]).id)]);
154
+ return {
155
+ slug, config: model, upstream: config.upstreams[model.upstream], profile, origin: source,
156
+ replay: { reasoning: allowed(model.replay?.reasoning_from), native_compaction: allowed(model.replay?.native_compaction_from) },
157
+ compaction: {
158
+ mode: model.compaction?.mode ?? (profile.native_compaction_format ? "native" : "summary"),
159
+ native_replay: model.compaction?.native_replay ?? !!profile.native_compaction_format,
160
+ },
161
+ };
162
+ }
163
+ /** Select one executor; the caller owns the generation mode, the executor owns replay. */
164
+ export function resolveCompactionModel(config, caller) {
165
+ const slug = caller.config.compaction?.model;
166
+ if (slug === undefined || slug === caller.slug)
167
+ return caller;
168
+ const executor = resolveModel(config, slug);
169
+ return { ...executor, compaction: { ...executor.compaction, mode: caller.compaction.mode } };
170
+ }
@@ -0,0 +1,43 @@
1
+ import { type HeaderRecord } from "./headers.ts";
2
+ export interface UpstreamFailure {
3
+ status?: number;
4
+ body: unknown;
5
+ }
6
+ /** Native Responses HTTP failures retain their transport-level meaning. */
7
+ export declare class UpstreamHttpError extends Error {
8
+ readonly status: number;
9
+ readonly body: string;
10
+ readonly headers: Headers;
11
+ constructor(status: number, body: string, headers: Headers);
12
+ }
13
+ /** A Responses failure belongs to the SSE protocol, not HTTP classification. */
14
+ export declare class ResponsesStreamError extends Error {
15
+ readonly error: Record<string, unknown>;
16
+ readonly response?: Record<string, any>;
17
+ readonly event?: Record<string, any>;
18
+ readonly headers?: Headers;
19
+ constructor(error: Record<string, unknown>, response?: Record<string, any>, event?: Record<string, any>, headers?: Headers);
20
+ }
21
+ /** An incomplete response has its own client termination and usage semantics. */
22
+ export declare class ResponsesIncompleteError extends Error {
23
+ readonly response: Record<string, any>;
24
+ readonly event?: Record<string, any>;
25
+ constructor(response: Record<string, any>, event?: Record<string, any>);
26
+ }
27
+ /** Client retry classification is separate from the unmodified upstream failure. */
28
+ export declare class BridgeError extends Error {
29
+ readonly code: string;
30
+ readonly status: number;
31
+ readonly headers?: HeaderRecord;
32
+ readonly upstream?: UpstreamFailure;
33
+ constructor(message: string, code?: string, status?: number, headers?: HeaderRecord, options?: {
34
+ upstream?: UpstreamFailure;
35
+ cause?: unknown;
36
+ });
37
+ }
38
+ /** Keep native fields; only supplement missing retry advice from HTTP headers. */
39
+ export declare function responsesStreamError(payload: unknown, headers?: Headers, event?: Record<string, any>): ResponsesStreamError;
40
+ /** Translate non-Responses protocols into errors understood by the client. */
41
+ export declare function upstreamError(status: number | undefined, payload: unknown, headers?: Headers): BridgeError;
42
+ /** Pick only diagnostics; fetch options and credential-command output must not escape. */
43
+ export declare function transportError(cause: unknown): BridgeError;