@leaves615/dsh-llm-ctl 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 +174 -0
- package/cordis.patch.yml +7 -0
- package/lib/client-plugin.d.ts +82 -0
- package/lib/client-plugin.js +685 -0
- package/lib/client.js +1712 -0
- package/lib/concurrency.d.ts +28 -0
- package/lib/concurrency.js +36 -0
- package/lib/config.d.ts +203 -0
- package/lib/config.js +67 -0
- package/lib/controller.d.ts +42 -0
- package/lib/controller.js +51 -0
- package/lib/delay.d.ts +68 -0
- package/lib/delay.js +134 -0
- package/lib/discover-ui.d.ts +94 -0
- package/lib/discover-ui.js +91 -0
- package/lib/discover.d.ts +79 -0
- package/lib/discover.js +141 -0
- package/lib/events.d.ts +45 -0
- package/lib/events.js +37 -0
- package/lib/index.d.ts +38 -0
- package/lib/index.js +378 -0
- package/lib/menu-filter.d.ts +134 -0
- package/lib/menu-filter.js +428 -0
- package/lib/menu-visibility.d.ts +26 -0
- package/lib/menu-visibility.js +77 -0
- package/lib/queue-dock.d.ts +85 -0
- package/lib/queue-dock.js +291 -0
- package/lib/queue.d.ts +128 -0
- package/lib/queue.js +313 -0
- package/lib/reactive.d.ts +57 -0
- package/lib/reactive.js +75 -0
- package/lib/reasoning-efforts.d.ts +120 -0
- package/lib/reasoning-efforts.js +143 -0
- package/lib/routes.d.ts +126 -0
- package/lib/routes.js +267 -0
- package/lib/settings-ui.d.ts +183 -0
- package/lib/settings-ui.js +367 -0
- package/lib/visibility-settings.d.ts +193 -0
- package/lib/visibility-settings.js +225 -0
- package/lib/visibility.d.ts +152 -0
- package/lib/visibility.js +235 -0
- package/package.json +92 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reasoning-effort declarations for hand-declared `llm-pi-ai` models.
|
|
3
|
+
*
|
|
4
|
+
* A custom provider model shows no effort picker because its materialized
|
|
5
|
+
* pi-ai descriptor carries no reasoning capability. The official fix is a
|
|
6
|
+
* per-model `reasoningEfforts` declaration (plus `compat.supportsReasoningEffort`)
|
|
7
|
+
* written into the `llm-pi-ai` settings section. Everything here is pure data
|
|
8
|
+
* shaping so it is testable without a browser or a settings provider.
|
|
9
|
+
*
|
|
10
|
+
* Official vocabulary (escalation order): off, minimal, low, medium, high,
|
|
11
|
+
* xhigh, max. Locked default (official-aligned): off/low/high/max with an
|
|
12
|
+
* identity wire mapping, matching the official deepseek adapter's
|
|
13
|
+
* Off/Low/High/Max ladder.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-llm-ctl/reasoning-efforts
|
|
16
|
+
*/
|
|
17
|
+
/** Settings namespace owning hand-declared pi-ai routes. */
|
|
18
|
+
export const PI_AI_SETTINGS_NS = 'llm-pi-ai';
|
|
19
|
+
/** Thinking levels in escalation order. */
|
|
20
|
+
export const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
21
|
+
/** Official-aligned default ladder: Off/Low/High/Max. */
|
|
22
|
+
export const DEFAULT_EFFORT_LEVELS = ['off', 'low', 'high', 'max'];
|
|
23
|
+
/** Advanced levels hidden behind the disclosure, off by default. */
|
|
24
|
+
export const ADVANCED_EFFORT_LEVELS = ['minimal', 'medium', 'xhigh'];
|
|
25
|
+
/**
|
|
26
|
+
* Build the default `reasoningEfforts` dict: every default level maps to
|
|
27
|
+
* itself, `off` included (explicit off switch, official-aligned).
|
|
28
|
+
*
|
|
29
|
+
* @param levels - levels to offer; defaults to the official ladder.
|
|
30
|
+
* @returns the dict to store on the model entry.
|
|
31
|
+
*/
|
|
32
|
+
export function defaultReasoningEfforts(levels = DEFAULT_EFFORT_LEVELS) {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (const level of levels)
|
|
35
|
+
out[level] = level;
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Validate a user-supplied effort dict against the official shape:
|
|
40
|
+
* non-empty, every key a known level, `off` may be string|null, every other
|
|
41
|
+
* declared level a non-empty string, and at least one level beyond `off`.
|
|
42
|
+
*
|
|
43
|
+
* @param value - candidate dict.
|
|
44
|
+
* @returns the failure reason, or undefined when valid.
|
|
45
|
+
*/
|
|
46
|
+
export function validateReasoningEfforts(value) {
|
|
47
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
48
|
+
return 'reasoningEfforts must be an object';
|
|
49
|
+
const entries = Object.entries(value);
|
|
50
|
+
if (entries.length === 0)
|
|
51
|
+
return 'declare at least one level, or reset to inherit';
|
|
52
|
+
const known = new Set(THINKING_LEVELS);
|
|
53
|
+
let beyondOff = false;
|
|
54
|
+
for (const [level, wire] of entries) {
|
|
55
|
+
if (!known.has(level))
|
|
56
|
+
return 'unknown thinking level: ' + level;
|
|
57
|
+
if (level !== 'off')
|
|
58
|
+
beyondOff = true;
|
|
59
|
+
if (wire === null) {
|
|
60
|
+
if (level !== 'off')
|
|
61
|
+
return 'only "off" may leave the wire value empty';
|
|
62
|
+
}
|
|
63
|
+
else if (typeof wire !== 'string' || wire.length === 0) {
|
|
64
|
+
return 'wire value for "' + level + '" must be a non-empty string';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!beyondOff)
|
|
68
|
+
return 'offer at least one level beyond "off"';
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Summarize one model row for the effort editor.
|
|
73
|
+
*
|
|
74
|
+
* @param input - provider identity, settings namespace, stored entry, live catalog levels.
|
|
75
|
+
* @returns facts the UI renders from.
|
|
76
|
+
*/
|
|
77
|
+
export function summarizeModelEffort(input) {
|
|
78
|
+
const stored = input.entry?.reasoningEfforts;
|
|
79
|
+
const valid = stored !== undefined && validateReasoningEfforts(stored) === undefined;
|
|
80
|
+
const dict = valid ? stored : undefined;
|
|
81
|
+
return {
|
|
82
|
+
provider: input.provider,
|
|
83
|
+
model: input.model,
|
|
84
|
+
writableNs: input.settingsNs === PI_AI_SETTINGS_NS,
|
|
85
|
+
declared: input.entry !== undefined,
|
|
86
|
+
...(dict === undefined ? {} : { stored: { ...dict } }),
|
|
87
|
+
levels: dict === undefined ? [] : Object.keys(dict),
|
|
88
|
+
...(input.liveLevels === undefined ? {} : { liveLevels: [...input.liveLevels] }),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Build the settings ops that declare efforts for one model.
|
|
93
|
+
*
|
|
94
|
+
* Hand-declared routes (`settingsPath = ["providers", route]`) store the
|
|
95
|
+
* whole `models` array, so the op sets the full array with the entry merged.
|
|
96
|
+
* Catalog routes store per-model overrides under
|
|
97
|
+
* `providers.<route>.modelOverrides.<id>`, so the op targets the
|
|
98
|
+
* `reasoningEfforts` (and compat flag) paths directly.
|
|
99
|
+
*
|
|
100
|
+
* @param input - route identity, current models array (hand-declared only), target model, effort dict.
|
|
101
|
+
* @returns ordered ops for `settings.mutate`.
|
|
102
|
+
*/
|
|
103
|
+
export function buildEffortOps(input) {
|
|
104
|
+
const compat = { supportsReasoningEffort: true };
|
|
105
|
+
if (input.models !== undefined) {
|
|
106
|
+
let found = false;
|
|
107
|
+
const next = input.models.map((entry) => {
|
|
108
|
+
if (entry.id !== input.model)
|
|
109
|
+
return entry;
|
|
110
|
+
found = true;
|
|
111
|
+
return { ...entry, reasoningEfforts: { ...input.efforts }, compat: { ...(entry.compat ?? {}), ...compat } };
|
|
112
|
+
});
|
|
113
|
+
const rows = found ? next : [...next, { id: input.model, reasoningEfforts: { ...input.efforts }, compat }];
|
|
114
|
+
return [{ op: 'set', path: ['providers', input.provider, 'models'], value: rows }];
|
|
115
|
+
}
|
|
116
|
+
return [
|
|
117
|
+
{ op: 'set', path: ['providers', input.provider, 'modelOverrides', input.model, 'reasoningEfforts'], value: { ...input.efforts } },
|
|
118
|
+
{ op: 'set', path: ['providers', input.provider, 'modelOverrides', input.model, 'compat', 'supportsReasoningEffort'], value: true },
|
|
119
|
+
];
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Build the settings ops that reset efforts for one model (back to inherit).
|
|
123
|
+
*
|
|
124
|
+
* @param input - route identity, hand-declared models array, target model.
|
|
125
|
+
* @returns ordered ops for `settings.mutate`.
|
|
126
|
+
*/
|
|
127
|
+
export function buildEffortResetOps(input) {
|
|
128
|
+
if (input.models !== undefined) {
|
|
129
|
+
const next = input.models.map((entry) => {
|
|
130
|
+
if (entry.id !== input.model)
|
|
131
|
+
return entry;
|
|
132
|
+
const { reasoningEfforts: _dropped, compat: currentCompat, ...kept } = entry;
|
|
133
|
+
void _dropped;
|
|
134
|
+
if (currentCompat === undefined)
|
|
135
|
+
return kept;
|
|
136
|
+
const { supportsReasoningEffort: _flag, ...rest } = currentCompat;
|
|
137
|
+
void _flag;
|
|
138
|
+
return Object.keys(rest).length === 0 ? kept : { ...kept, compat: rest };
|
|
139
|
+
});
|
|
140
|
+
return [{ op: 'set', path: ['providers', input.provider, 'models'], value: next }];
|
|
141
|
+
}
|
|
142
|
+
return [{ op: 'unset', path: ['providers', input.provider, 'modelOverrides', input.model, 'reasoningEfforts'] }];
|
|
143
|
+
}
|
package/lib/routes.d.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser channel for the control plane.
|
|
3
|
+
*
|
|
4
|
+
* A Typert Remote namespace is not reachable from an out-of-repo client half:
|
|
5
|
+
* the browser proxy in \`@deepseek-ai/dsh-api-remotes\` is generated per known
|
|
6
|
+
* namespace, so a third-party namespace never appears on \`ctx.remote\`. The
|
|
7
|
+
* supported channel for a plugin is therefore a plain HTTP route registered on
|
|
8
|
+
* the optional \`webServer\` service (the same seam \`@linxin666/dsh-doctor\` uses).
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-llm-ctl/routes
|
|
11
|
+
*/
|
|
12
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
13
|
+
import type { CtlEvent } from './events.ts';
|
|
14
|
+
import type { GateSnapshot } from './queue.ts';
|
|
15
|
+
import type { VisibilitySettings } from './visibility.ts';
|
|
16
|
+
/** Route shape accepted by \`ctx.webServer.register\`. */
|
|
17
|
+
export interface WebRoute {
|
|
18
|
+
kind: 'exact' | 'prefix';
|
|
19
|
+
path: string;
|
|
20
|
+
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
/** Minimal slice of the host web server the plugin needs. */
|
|
23
|
+
export interface WebServerLike {
|
|
24
|
+
register(route: WebRoute): () => void;
|
|
25
|
+
}
|
|
26
|
+
/** Effective global queue budget surfaced to the settings UI. */
|
|
27
|
+
export interface QueueConfigState {
|
|
28
|
+
/** Effective maxWaitMs in ms (settings override wins over the cordis base). */
|
|
29
|
+
maxWaitMs: number;
|
|
30
|
+
/** Effective maxQueueDepth (settings override wins over the cordis base). */
|
|
31
|
+
maxQueueDepth: number;
|
|
32
|
+
/** Effective default per-provider concurrency; `0` means unlimited. */
|
|
33
|
+
defaultConcurrency: number;
|
|
34
|
+
/** Effective provider-specific concurrency entries, excluding `default`; `0` means unlimited. */
|
|
35
|
+
perProviderConcurrency: Record<string, number>;
|
|
36
|
+
/** Cordis composition base, before the user-layer override. */
|
|
37
|
+
defaults: {
|
|
38
|
+
maxWaitMs: number;
|
|
39
|
+
maxQueueDepth: number;
|
|
40
|
+
defaultConcurrency: number;
|
|
41
|
+
};
|
|
42
|
+
/** True when at least one field carries a user-layer override. */
|
|
43
|
+
overridden: boolean;
|
|
44
|
+
/** Current settings section revision, for write fencing. */
|
|
45
|
+
revision: number;
|
|
46
|
+
}
|
|
47
|
+
/** Visibility slice of the state payload. */
|
|
48
|
+
export interface VisibilityState {
|
|
49
|
+
settings: VisibilitySettings;
|
|
50
|
+
/** Composition preset patterns, read-only. */
|
|
51
|
+
patterns: readonly string[];
|
|
52
|
+
/** Declared provider directory, including providers absent from the catalog. */
|
|
53
|
+
configurableProviders: ConfigurableProviderView[];
|
|
54
|
+
}
|
|
55
|
+
/** State payload the browser polls. */
|
|
56
|
+
export interface ControlState {
|
|
57
|
+
at: number;
|
|
58
|
+
queue: GateSnapshot;
|
|
59
|
+
events: CtlEvent[];
|
|
60
|
+
reactive: {
|
|
61
|
+
mode: 'auto' | 'off' | number;
|
|
62
|
+
limit: number;
|
|
63
|
+
};
|
|
64
|
+
queueConfig: QueueConfigState;
|
|
65
|
+
visibility: VisibilityState;
|
|
66
|
+
}
|
|
67
|
+
/** Result of one visibility write. */
|
|
68
|
+
export interface VisibilityWriteOutcome {
|
|
69
|
+
ok: boolean;
|
|
70
|
+
code?: string;
|
|
71
|
+
message?: string;
|
|
72
|
+
revision?: number;
|
|
73
|
+
}
|
|
74
|
+
/** Configurable provider directory entry surfaced to the browser. */
|
|
75
|
+
export interface ConfigurableProviderView {
|
|
76
|
+
provider: string;
|
|
77
|
+
displayName: string;
|
|
78
|
+
settingsNs: string;
|
|
79
|
+
}
|
|
80
|
+
/** Callbacks the routes delegate to. */
|
|
81
|
+
export interface RouteDeps {
|
|
82
|
+
state: () => ControlState;
|
|
83
|
+
cancel: (queueId: string) => boolean;
|
|
84
|
+
setQueue: (input: {
|
|
85
|
+
maxWaitMs?: number | undefined;
|
|
86
|
+
maxQueueDepth?: number | undefined;
|
|
87
|
+
defaultConcurrency?: number | undefined;
|
|
88
|
+
perProviderConcurrency?: Record<string, number> | undefined;
|
|
89
|
+
expectedRevision?: number;
|
|
90
|
+
}) => Promise<VisibilityWriteOutcome>;
|
|
91
|
+
resetQueue: (input: {
|
|
92
|
+
expectedRevision?: number;
|
|
93
|
+
}) => Promise<VisibilityWriteOutcome>;
|
|
94
|
+
setVisibility: (input: {
|
|
95
|
+
provider: string;
|
|
96
|
+
model?: string;
|
|
97
|
+
visible: boolean;
|
|
98
|
+
}) => Promise<VisibilityWriteOutcome>;
|
|
99
|
+
resetVisibility: () => Promise<VisibilityWriteOutcome>;
|
|
100
|
+
discover: (input: {
|
|
101
|
+
provider: string;
|
|
102
|
+
baseURL?: string;
|
|
103
|
+
api?: string;
|
|
104
|
+
}) => Promise<unknown>;
|
|
105
|
+
}
|
|
106
|
+
/** Path of the polled state document. */
|
|
107
|
+
export declare const STATE_PATH = "/api/llm-ctl/state";
|
|
108
|
+
/** Path of the cancel action. */
|
|
109
|
+
export declare const CANCEL_PATH = "/api/llm-ctl/cancel";
|
|
110
|
+
/** Path of the visibility write action. */
|
|
111
|
+
export declare const VISIBILITY_PATH = "/api/llm-ctl/visibility";
|
|
112
|
+
/** Path of the visibility reset action. */
|
|
113
|
+
export declare const VISIBILITY_RESET_PATH = "/api/llm-ctl/visibility/reset";
|
|
114
|
+
/** Path of the upstream model discovery action. */
|
|
115
|
+
export declare const DISCOVER_PATH = "/api/llm-ctl/discover";
|
|
116
|
+
/** Path of the global queue-budget write action. */
|
|
117
|
+
export declare const QUEUE_PATH = "/api/llm-ctl/queue";
|
|
118
|
+
/** Path of the global queue-budget reset action. */
|
|
119
|
+
export declare const QUEUE_RESET_PATH = "/api/llm-ctl/queue/reset";
|
|
120
|
+
/**
|
|
121
|
+
* Build the two routes the browser half uses.
|
|
122
|
+
*
|
|
123
|
+
* @param deps - state reader and cancel action.
|
|
124
|
+
* @returns route descriptors ready for \`webServer.register\`.
|
|
125
|
+
*/
|
|
126
|
+
export declare function createRoutes(deps: RouteDeps): WebRoute[];
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/** Path of the polled state document. */
|
|
2
|
+
export const STATE_PATH = '/api/llm-ctl/state';
|
|
3
|
+
/** Path of the cancel action. */
|
|
4
|
+
export const CANCEL_PATH = '/api/llm-ctl/cancel';
|
|
5
|
+
/** Path of the visibility write action. */
|
|
6
|
+
export const VISIBILITY_PATH = '/api/llm-ctl/visibility';
|
|
7
|
+
/** Path of the visibility reset action. */
|
|
8
|
+
export const VISIBILITY_RESET_PATH = '/api/llm-ctl/visibility/reset';
|
|
9
|
+
/** Path of the upstream model discovery action. */
|
|
10
|
+
export const DISCOVER_PATH = '/api/llm-ctl/discover';
|
|
11
|
+
/** Path of the global queue-budget write action. */
|
|
12
|
+
export const QUEUE_PATH = '/api/llm-ctl/queue';
|
|
13
|
+
/** Path of the global queue-budget reset action. */
|
|
14
|
+
export const QUEUE_RESET_PATH = '/api/llm-ctl/queue/reset';
|
|
15
|
+
/** Write one JSON response with no caching. */
|
|
16
|
+
function writeJson(res, status, body) {
|
|
17
|
+
const payload = JSON.stringify(body);
|
|
18
|
+
res.writeHead(status, {
|
|
19
|
+
'content-type': 'application/json; charset=utf-8',
|
|
20
|
+
'cache-control': 'no-store',
|
|
21
|
+
'content-length': Buffer.byteLength(payload),
|
|
22
|
+
});
|
|
23
|
+
res.end(payload);
|
|
24
|
+
}
|
|
25
|
+
/** Read a bounded JSON request body. */
|
|
26
|
+
async function readJson(req, limitBytes = 4_096) {
|
|
27
|
+
const chunks = [];
|
|
28
|
+
let total = 0;
|
|
29
|
+
for await (const chunk of req) {
|
|
30
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
31
|
+
total += buffer.length;
|
|
32
|
+
if (total > limitBytes)
|
|
33
|
+
throw new Error('request body too large');
|
|
34
|
+
chunks.push(buffer);
|
|
35
|
+
}
|
|
36
|
+
if (total === 0)
|
|
37
|
+
return undefined;
|
|
38
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build the two routes the browser half uses.
|
|
42
|
+
*
|
|
43
|
+
* @param deps - state reader and cancel action.
|
|
44
|
+
* @returns route descriptors ready for \`webServer.register\`.
|
|
45
|
+
*/
|
|
46
|
+
export function createRoutes(deps) {
|
|
47
|
+
return [
|
|
48
|
+
{
|
|
49
|
+
kind: 'exact',
|
|
50
|
+
path: STATE_PATH,
|
|
51
|
+
handler: (_req, res) => {
|
|
52
|
+
try {
|
|
53
|
+
writeJson(res, 200, deps.state());
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
kind: 'exact',
|
|
62
|
+
path: CANCEL_PATH,
|
|
63
|
+
handler: async (req, res) => {
|
|
64
|
+
if (req.method !== 'POST') {
|
|
65
|
+
writeJson(res, 405, { error: 'method not allowed' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const body = await readJson(req);
|
|
70
|
+
const queueId = body !== null && typeof body === 'object' ? body.queueId : undefined;
|
|
71
|
+
if (typeof queueId !== 'string' || queueId.length === 0) {
|
|
72
|
+
writeJson(res, 400, { error: 'queueId is required' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
writeJson(res, 200, { cancelled: deps.cancel(queueId) });
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
kind: 'exact',
|
|
84
|
+
path: VISIBILITY_PATH,
|
|
85
|
+
handler: async (req, res) => {
|
|
86
|
+
if (req.method !== 'POST') {
|
|
87
|
+
writeJson(res, 405, { error: 'method not allowed' });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const body = await readJson(req);
|
|
92
|
+
const record = body !== null && typeof body === 'object' ? body : {};
|
|
93
|
+
const provider = record['provider'];
|
|
94
|
+
const model = record['model'];
|
|
95
|
+
const visible = record['visible'];
|
|
96
|
+
if (typeof provider !== 'string' || provider.length === 0 || typeof visible !== 'boolean') {
|
|
97
|
+
writeJson(res, 400, { error: 'provider and visible are required' });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (model !== undefined && typeof model !== 'string') {
|
|
101
|
+
writeJson(res, 400, { error: 'model must be a string' });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const outcome = await deps.setVisibility({
|
|
105
|
+
provider,
|
|
106
|
+
...(typeof model === 'string' && model.length > 0 ? { model } : {}),
|
|
107
|
+
visible,
|
|
108
|
+
});
|
|
109
|
+
writeJson(res, outcome.ok ? 200 : 409, outcome);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
kind: 'exact',
|
|
118
|
+
path: VISIBILITY_RESET_PATH,
|
|
119
|
+
handler: async (req, res) => {
|
|
120
|
+
if (req.method !== 'POST') {
|
|
121
|
+
writeJson(res, 405, { error: 'method not allowed' });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const outcome = await deps.resetVisibility();
|
|
126
|
+
writeJson(res, outcome.ok ? 200 : 409, outcome);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
kind: 'exact',
|
|
135
|
+
path: QUEUE_PATH,
|
|
136
|
+
handler: async (req, res) => {
|
|
137
|
+
if (req.method !== 'POST') {
|
|
138
|
+
writeJson(res, 405, { error: 'method not allowed' });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
const body = await readJson(req);
|
|
143
|
+
const record = body !== null && typeof body === 'object' ? body : {};
|
|
144
|
+
const input = {};
|
|
145
|
+
if (record['maxWaitMs'] !== undefined) {
|
|
146
|
+
if (typeof record['maxWaitMs'] !== 'number' || !Number.isFinite(record['maxWaitMs']) || record['maxWaitMs'] < 0) {
|
|
147
|
+
writeJson(res, 400, { error: 'maxWaitMs must be a number >= 0' });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
input.maxWaitMs = record['maxWaitMs'];
|
|
151
|
+
}
|
|
152
|
+
if (record['maxQueueDepth'] !== undefined) {
|
|
153
|
+
if (typeof record['maxQueueDepth'] !== 'number' || !Number.isFinite(record['maxQueueDepth']) || record['maxQueueDepth'] < 1) {
|
|
154
|
+
writeJson(res, 400, { error: 'maxQueueDepth must be a number >= 1' });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
input.maxQueueDepth = Math.floor(record['maxQueueDepth']);
|
|
158
|
+
}
|
|
159
|
+
if (record['defaultConcurrency'] !== undefined) {
|
|
160
|
+
if (typeof record['defaultConcurrency'] !== 'number' || !Number.isFinite(record['defaultConcurrency']) || record['defaultConcurrency'] < 0) {
|
|
161
|
+
writeJson(res, 400, { error: 'defaultConcurrency must be a number >= 0 (0 means unlimited)' });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
input.defaultConcurrency = Math.floor(record['defaultConcurrency']);
|
|
165
|
+
}
|
|
166
|
+
if (record['perProviderConcurrency'] !== undefined) {
|
|
167
|
+
const table = record['perProviderConcurrency'];
|
|
168
|
+
if (typeof table !== 'object' || table === null || Array.isArray(table)) {
|
|
169
|
+
writeJson(res, 400, { error: 'perProviderConcurrency must be an object' });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const entries = {};
|
|
173
|
+
for (const [key, value] of Object.entries(table)) {
|
|
174
|
+
if (key.length === 0 || typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
175
|
+
writeJson(res, 400, { error: `perProviderConcurrency[${JSON.stringify(key)}] must be a number >= 0 (0 means unlimited)` });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
entries[key] = Math.floor(value);
|
|
179
|
+
}
|
|
180
|
+
input.perProviderConcurrency = entries;
|
|
181
|
+
}
|
|
182
|
+
if (record['expectedRevision'] !== undefined) {
|
|
183
|
+
if (typeof record['expectedRevision'] !== 'number' || !Number.isFinite(record['expectedRevision']) || record['expectedRevision'] < 0) {
|
|
184
|
+
writeJson(res, 400, { error: 'expectedRevision must be a number >= 0' });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
input.expectedRevision = Math.floor(record['expectedRevision']);
|
|
188
|
+
}
|
|
189
|
+
if (input.maxWaitMs === undefined && input.maxQueueDepth === undefined && input.defaultConcurrency === undefined && input.perProviderConcurrency === undefined) {
|
|
190
|
+
writeJson(res, 400, { error: 'maxWaitMs, maxQueueDepth, defaultConcurrency, or perProviderConcurrency is required' });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const outcome = await deps.setQueue(input);
|
|
194
|
+
writeJson(res, outcome.ok ? 200 : 409, outcome);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
kind: 'exact',
|
|
203
|
+
path: QUEUE_RESET_PATH,
|
|
204
|
+
handler: async (req, res) => {
|
|
205
|
+
if (req.method !== 'POST') {
|
|
206
|
+
writeJson(res, 405, { error: 'method not allowed' });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const body = await readJson(req);
|
|
211
|
+
const record = body !== null && typeof body === 'object' ? body : {};
|
|
212
|
+
const input = {};
|
|
213
|
+
if (record['expectedRevision'] !== undefined) {
|
|
214
|
+
if (typeof record['expectedRevision'] !== 'number' || !Number.isFinite(record['expectedRevision']) || record['expectedRevision'] < 0) {
|
|
215
|
+
writeJson(res, 400, { error: 'expectedRevision must be a number >= 0' });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
input.expectedRevision = Math.floor(record['expectedRevision']);
|
|
219
|
+
}
|
|
220
|
+
const outcome = await deps.resetQueue(input);
|
|
221
|
+
writeJson(res, outcome.ok ? 200 : 409, outcome);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
kind: 'exact',
|
|
230
|
+
path: DISCOVER_PATH,
|
|
231
|
+
handler: async (req, res) => {
|
|
232
|
+
if (req.method !== 'POST') {
|
|
233
|
+
writeJson(res, 405, { error: 'method not allowed' });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const body = await readJson(req);
|
|
238
|
+
const record = body !== null && typeof body === 'object' ? body : {};
|
|
239
|
+
const provider = record['provider'];
|
|
240
|
+
if (typeof provider !== 'string' || provider.length === 0) {
|
|
241
|
+
writeJson(res, 400, { error: 'provider is required' });
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const optional = (key) => {
|
|
245
|
+
const value = record[key];
|
|
246
|
+
if (value === undefined)
|
|
247
|
+
return undefined;
|
|
248
|
+
if (typeof value !== 'string')
|
|
249
|
+
throw new Error(key + ' must be a string');
|
|
250
|
+
return value;
|
|
251
|
+
};
|
|
252
|
+
// Secrets never cross this channel: discovery reuses the stored
|
|
253
|
+
// credential server-side. An `apiKey` field in the body is rejected
|
|
254
|
+
// so a stray or malicious caller cannot smuggle one through logs.
|
|
255
|
+
if (record['apiKey'] !== undefined) {
|
|
256
|
+
writeJson(res, 400, { error: 'apiKey must not be sent; discovery uses the stored credential' });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
writeJson(res, 200, await deps.discover({ provider, baseURL: optional('baseURL'), api: optional('api') }));
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
];
|
|
267
|
+
}
|