llm-cli-gateway 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +79 -9
- package/README.md +3 -1
- package/dist/auth.d.ts +44 -1
- package/dist/auth.js +60 -13
- package/dist/config.d.ts +19 -0
- package/dist/config.js +235 -0
- package/dist/doctor.d.ts +15 -0
- package/dist/doctor.js +22 -11
- package/dist/executor.js +17 -21
- package/dist/flight-recorder.d.ts +2 -1
- package/dist/http-transport.js +74 -12
- package/dist/index.d.ts +42 -7
- package/dist/index.js +1161 -82
- package/dist/metrics.d.ts +3 -3
- package/dist/metrics.js +8 -8
- package/dist/oauth.d.ts +38 -0
- package/dist/oauth.js +441 -0
- package/dist/request-context.d.ts +7 -0
- package/dist/request-context.js +8 -0
- package/dist/request-helpers.d.ts +8 -8
- package/dist/resources.js +56 -7
- package/dist/session-manager-pg.d.ts +6 -6
- package/dist/session-manager-pg.js +1 -0
- package/dist/session-manager.d.ts +16 -12
- package/dist/session-manager.js +4 -1
- package/dist/upstream-contracts.d.ts +84 -0
- package/dist/upstream-contracts.js +714 -6
- package/dist/workspace-registry.d.ts +63 -0
- package/dist/workspace-registry.js +417 -0
- package/dist/xai-api-provider.d.ts +43 -0
- package/dist/xai-api-provider.js +191 -0
- package/migrations/001_initial_schema.sql +65 -0
- package/migrations/002_session_ids_as_text.sql +26 -0
- package/migrations/003_provider_type_sessions.sql +20 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -1
- package/setup/status.schema.json +42 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { request as httpRequest } from "node:http";
|
|
2
|
+
import { request as httpsRequest } from "node:https";
|
|
3
|
+
import { URL } from "node:url";
|
|
4
|
+
import { createCircuitBreaker, withRetry } from "./retry.js";
|
|
5
|
+
import { logWarn, noopLogger } from "./logger.js";
|
|
6
|
+
const MAX_RESPONSE_BYTES = 50 * 1024 * 1024;
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 600_000;
|
|
8
|
+
export class XaiApiError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
responseText;
|
|
11
|
+
code;
|
|
12
|
+
constructor(message, status = null, responseText = "", code) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.responseText = responseText;
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.name = "XaiApiError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
let xaiCircuitBreaker = null;
|
|
21
|
+
function getXaiCircuitBreaker(logger) {
|
|
22
|
+
xaiCircuitBreaker ??= createCircuitBreaker({
|
|
23
|
+
failureThreshold: 3,
|
|
24
|
+
resetTimeout: 60_000,
|
|
25
|
+
onStateChange: state => logWarn(logger, `[xai-api] circuit breaker state changed to ${state}`),
|
|
26
|
+
});
|
|
27
|
+
return xaiCircuitBreaker;
|
|
28
|
+
}
|
|
29
|
+
function isHttpTransient(error) {
|
|
30
|
+
const status = typeof error?.status === "number" ? error.status : null;
|
|
31
|
+
if (status === 429 || (status !== null && status >= 500))
|
|
32
|
+
return true;
|
|
33
|
+
return ["ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "EPIPE"].includes(String(error?.code ?? ""));
|
|
34
|
+
}
|
|
35
|
+
function responsesUrl(baseUrl) {
|
|
36
|
+
const trimmed = baseUrl.replace(/\/+$/, "");
|
|
37
|
+
const url = new URL(`${trimmed}/responses`);
|
|
38
|
+
if (url.protocol !== "https:" &&
|
|
39
|
+
!(url.protocol === "http:" && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname))) {
|
|
40
|
+
throw new XaiApiError("xAI API baseUrl must use https unless it targets localhost/loopback");
|
|
41
|
+
}
|
|
42
|
+
return url;
|
|
43
|
+
}
|
|
44
|
+
function extractErrorMessage(status, body) {
|
|
45
|
+
if (!body)
|
|
46
|
+
return `xAI API request failed with HTTP ${status}`;
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(body);
|
|
49
|
+
const message = parsed?.error?.message ?? parsed?.message ?? parsed?.error;
|
|
50
|
+
if (typeof message === "string" && message.length > 0) {
|
|
51
|
+
return `xAI API request failed with HTTP ${status}: ${message}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
}
|
|
56
|
+
return `xAI API request failed with HTTP ${status}: ${body.slice(0, 1000)}`;
|
|
57
|
+
}
|
|
58
|
+
function normalizeCostUsd(usage) {
|
|
59
|
+
const ticks = usage?.cost_in_usd_ticks;
|
|
60
|
+
if (typeof ticks === "number" && Number.isFinite(ticks))
|
|
61
|
+
return ticks / 10_000_000_000;
|
|
62
|
+
const nanos = usage?.cost_in_nano_usd;
|
|
63
|
+
if (typeof nanos === "number" && Number.isFinite(nanos))
|
|
64
|
+
return nanos / 1_000_000_000;
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
function extractResponseText(parsed) {
|
|
68
|
+
const output = Array.isArray(parsed?.output) ? parsed.output : [];
|
|
69
|
+
const chunks = [];
|
|
70
|
+
for (const item of output) {
|
|
71
|
+
if (item?.type !== "message" || !Array.isArray(item.content))
|
|
72
|
+
continue;
|
|
73
|
+
for (const content of item.content) {
|
|
74
|
+
if ((content?.type === "output_text" || content?.type === "text") &&
|
|
75
|
+
typeof content.text === "string") {
|
|
76
|
+
chunks.push(content.text);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (chunks.length > 0)
|
|
81
|
+
return chunks.join("");
|
|
82
|
+
if (typeof parsed?.output_text === "string")
|
|
83
|
+
return parsed.output_text;
|
|
84
|
+
return "";
|
|
85
|
+
}
|
|
86
|
+
function parseResponsesResult(status, body) {
|
|
87
|
+
const parsed = JSON.parse(body);
|
|
88
|
+
const usage = parsed?.usage ?? {};
|
|
89
|
+
return {
|
|
90
|
+
responseId: typeof parsed?.id === "string" ? parsed.id : null,
|
|
91
|
+
model: typeof parsed?.model === "string" ? parsed.model : "unknown",
|
|
92
|
+
status: typeof parsed?.status === "string" ? parsed.status : null,
|
|
93
|
+
text: extractResponseText(parsed),
|
|
94
|
+
usage: {
|
|
95
|
+
inputTokens: typeof usage.input_tokens === "number"
|
|
96
|
+
? usage.input_tokens
|
|
97
|
+
: typeof usage.prompt_tokens === "number"
|
|
98
|
+
? usage.prompt_tokens
|
|
99
|
+
: undefined,
|
|
100
|
+
outputTokens: typeof usage.output_tokens === "number"
|
|
101
|
+
? usage.output_tokens
|
|
102
|
+
: typeof usage.completion_tokens === "number"
|
|
103
|
+
? usage.completion_tokens
|
|
104
|
+
: undefined,
|
|
105
|
+
cacheReadTokens: typeof usage?.input_tokens_details?.cached_tokens === "number"
|
|
106
|
+
? usage.input_tokens_details.cached_tokens
|
|
107
|
+
: typeof usage?.prompt_tokens_details?.cached_tokens === "number"
|
|
108
|
+
? usage.prompt_tokens_details.cached_tokens
|
|
109
|
+
: undefined,
|
|
110
|
+
costUsd: normalizeCostUsd(usage),
|
|
111
|
+
raw: usage,
|
|
112
|
+
},
|
|
113
|
+
raw: parsed,
|
|
114
|
+
httpStatus: status,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function postJson(url, body, apiKey, timeoutMs) {
|
|
118
|
+
const payload = JSON.stringify(body);
|
|
119
|
+
const requester = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
const req = requester(url, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
timeout: timeoutMs,
|
|
124
|
+
headers: {
|
|
125
|
+
authorization: `Bearer ${apiKey}`,
|
|
126
|
+
"content-type": "application/json",
|
|
127
|
+
accept: "application/json",
|
|
128
|
+
"content-length": Buffer.byteLength(payload),
|
|
129
|
+
},
|
|
130
|
+
}, res => {
|
|
131
|
+
const chunks = [];
|
|
132
|
+
let bytes = 0;
|
|
133
|
+
res.on("data", chunk => {
|
|
134
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
135
|
+
bytes += buf.length;
|
|
136
|
+
if (bytes > MAX_RESPONSE_BYTES) {
|
|
137
|
+
req.destroy(new XaiApiError("xAI API response exceeded the 50MB limit", null));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
chunks.push(buf);
|
|
141
|
+
});
|
|
142
|
+
res.on("end", () => {
|
|
143
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
144
|
+
const status = res.statusCode ?? 0;
|
|
145
|
+
if (status < 200 || status >= 300) {
|
|
146
|
+
const err = new XaiApiError(extractErrorMessage(status, text), status, text);
|
|
147
|
+
reject(err);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
resolve(text);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
req.on("timeout", () => {
|
|
154
|
+
req.destroy(new XaiApiError("xAI API request timed out", null, "", "ETIMEDOUT"));
|
|
155
|
+
});
|
|
156
|
+
req.on("error", reject);
|
|
157
|
+
req.end(payload);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
export async function createXaiResponse(params, logger = noopLogger) {
|
|
161
|
+
const requestBody = {
|
|
162
|
+
model: params.model,
|
|
163
|
+
input: params.input,
|
|
164
|
+
store: true,
|
|
165
|
+
};
|
|
166
|
+
if (params.instructions)
|
|
167
|
+
requestBody.instructions = params.instructions;
|
|
168
|
+
if (params.previousResponseId)
|
|
169
|
+
requestBody.previous_response_id = params.previousResponseId;
|
|
170
|
+
if (params.maxOutputTokens !== undefined)
|
|
171
|
+
requestBody.max_output_tokens = params.maxOutputTokens;
|
|
172
|
+
if (params.temperature !== undefined)
|
|
173
|
+
requestBody.temperature = params.temperature;
|
|
174
|
+
if (params.topP !== undefined)
|
|
175
|
+
requestBody.top_p = params.topP;
|
|
176
|
+
if (params.reasoningEffort !== undefined) {
|
|
177
|
+
requestBody.reasoning = { effort: params.reasoningEffort };
|
|
178
|
+
}
|
|
179
|
+
const url = responsesUrl(params.baseUrl);
|
|
180
|
+
const timeoutMs = params.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
181
|
+
const body = await withRetry(() => postJson(url, requestBody, params.apiKey, timeoutMs), getXaiCircuitBreaker(logger), {
|
|
182
|
+
initialDelay: 1_000,
|
|
183
|
+
maxDelay: 30_000,
|
|
184
|
+
factor: 2,
|
|
185
|
+
isTransient: isHttpTransient,
|
|
186
|
+
onRetry: (error, attempt, delay) => {
|
|
187
|
+
logWarn(logger, `[xai-api] transient request failure on attempt ${attempt}; retrying in ${delay}ms: ${error.message}`);
|
|
188
|
+
},
|
|
189
|
+
}, logger);
|
|
190
|
+
return parseResponsesResult(200, body);
|
|
191
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
-- Initial schema for llm-cli-gateway PostgreSQL backend
|
|
2
|
+
-- Sessions and active session management
|
|
3
|
+
|
|
4
|
+
-- Create sessions table
|
|
5
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
cli VARCHAR(32) NOT NULL CHECK (cli IN ('claude', 'codex', 'gemini', 'grok', 'mistral', 'grok-api')),
|
|
8
|
+
description TEXT,
|
|
9
|
+
metadata JSONB DEFAULT '{}'::JSONB,
|
|
10
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
11
|
+
last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
-- Create active_sessions table (enforces one active per CLI)
|
|
15
|
+
CREATE TABLE IF NOT EXISTS active_sessions (
|
|
16
|
+
cli VARCHAR(32) PRIMARY KEY CHECK (cli IN ('claude', 'codex', 'gemini', 'grok', 'mistral', 'grok-api')),
|
|
17
|
+
session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE,
|
|
18
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
-- Indexes for performance
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_cli ON sessions(cli);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_last_used_at ON sessions(last_used_at DESC);
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_metadata ON sessions USING GIN(metadata);
|
|
25
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_cli_last_used ON sessions(cli, last_used_at DESC);
|
|
26
|
+
|
|
27
|
+
-- View for session summary (joins sessions + active_sessions)
|
|
28
|
+
CREATE OR REPLACE VIEW session_summary AS
|
|
29
|
+
SELECT
|
|
30
|
+
s.id,
|
|
31
|
+
s.cli,
|
|
32
|
+
s.description,
|
|
33
|
+
s.created_at,
|
|
34
|
+
s.last_used_at,
|
|
35
|
+
(a.session_id IS NOT NULL) AS is_active
|
|
36
|
+
FROM sessions s
|
|
37
|
+
LEFT JOIN active_sessions a ON s.id = a.session_id;
|
|
38
|
+
|
|
39
|
+
-- Cleanup function for expired sessions
|
|
40
|
+
CREATE OR REPLACE FUNCTION cleanup_expired_sessions(max_age_days INTEGER DEFAULT 30)
|
|
41
|
+
RETURNS INTEGER AS $$
|
|
42
|
+
DECLARE
|
|
43
|
+
deleted_count INTEGER;
|
|
44
|
+
BEGIN
|
|
45
|
+
-- Delete sessions older than max_age_days that are not active
|
|
46
|
+
DELETE FROM sessions
|
|
47
|
+
WHERE last_used_at < NOW() - INTERVAL '1 day' * max_age_days
|
|
48
|
+
AND id NOT IN (SELECT session_id FROM active_sessions WHERE session_id IS NOT NULL);
|
|
49
|
+
|
|
50
|
+
GET DIAGNOSTICS deleted_count = ROW_COUNT;
|
|
51
|
+
RETURN deleted_count;
|
|
52
|
+
END;
|
|
53
|
+
$$ LANGUAGE plpgsql;
|
|
54
|
+
|
|
55
|
+
-- Schema migrations tracking table
|
|
56
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
57
|
+
version INTEGER PRIMARY KEY,
|
|
58
|
+
name VARCHAR(255) NOT NULL,
|
|
59
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
-- Record this migration
|
|
63
|
+
INSERT INTO schema_migrations (version, name)
|
|
64
|
+
VALUES (1, '001_initial_schema')
|
|
65
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- Convert session identifiers from UUID to opaque string IDs (TEXT)
|
|
2
|
+
-- Keeps compatibility with file-based manager and legacy custom IDs.
|
|
3
|
+
|
|
4
|
+
DO $$
|
|
5
|
+
BEGIN
|
|
6
|
+
IF EXISTS (
|
|
7
|
+
SELECT 1
|
|
8
|
+
FROM information_schema.columns
|
|
9
|
+
WHERE table_schema = 'public'
|
|
10
|
+
AND table_name = 'sessions'
|
|
11
|
+
AND column_name = 'id'
|
|
12
|
+
AND udt_name = 'uuid'
|
|
13
|
+
) THEN
|
|
14
|
+
ALTER TABLE active_sessions DROP CONSTRAINT IF EXISTS active_sessions_session_id_fkey;
|
|
15
|
+
ALTER TABLE sessions ALTER COLUMN id TYPE TEXT USING id::text;
|
|
16
|
+
ALTER TABLE active_sessions ALTER COLUMN session_id TYPE TEXT USING session_id::text;
|
|
17
|
+
ALTER TABLE active_sessions
|
|
18
|
+
ADD CONSTRAINT active_sessions_session_id_fkey
|
|
19
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE;
|
|
20
|
+
END IF;
|
|
21
|
+
END;
|
|
22
|
+
$$ LANGUAGE plpgsql;
|
|
23
|
+
|
|
24
|
+
INSERT INTO schema_migrations (version, name)
|
|
25
|
+
VALUES (2, '002_session_ids_as_text')
|
|
26
|
+
ON CONFLICT (version) DO NOTHING;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- Widen session provider constraints for API-backed providers.
|
|
2
|
+
-- Existing PostgreSQL installations created before the Grok API provider split
|
|
3
|
+
-- only accepted the original CLI subset. Keep the column values opaque strings
|
|
4
|
+
-- but enforce the current provider set.
|
|
5
|
+
|
|
6
|
+
ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_cli_check;
|
|
7
|
+
ALTER TABLE sessions ALTER COLUMN cli TYPE VARCHAR(32);
|
|
8
|
+
ALTER TABLE sessions
|
|
9
|
+
ADD CONSTRAINT sessions_cli_check
|
|
10
|
+
CHECK (cli IN ('claude', 'codex', 'gemini', 'grok', 'mistral', 'grok-api'));
|
|
11
|
+
|
|
12
|
+
ALTER TABLE active_sessions DROP CONSTRAINT IF EXISTS active_sessions_cli_check;
|
|
13
|
+
ALTER TABLE active_sessions ALTER COLUMN cli TYPE VARCHAR(32);
|
|
14
|
+
ALTER TABLE active_sessions
|
|
15
|
+
ADD CONSTRAINT active_sessions_cli_check
|
|
16
|
+
CHECK (cli IN ('claude', 'codex', 'gemini', 'grok', 'mistral', 'grok-api'));
|
|
17
|
+
|
|
18
|
+
INSERT INTO schema_migrations (version, name)
|
|
19
|
+
VALUES (3, '003_provider_type_sessions')
|
|
20
|
+
ON CONFLICT (version) DO NOTHING;
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llm-cli-gateway",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "llm-cli-gateway",
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.5.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "llm-cli-gateway",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"mcpName": "io.github.verivus-oss/llm-cli-gateway",
|
|
5
5
|
"description": "MCP server providing unified access to Claude Code, Codex, Gemini, Grok, and Mistral Vibe CLIs with session management, retry logic, async job orchestration, durable job results, and cross-LLM validation.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"dist/**/*.js",
|
|
47
47
|
"dist/**/*.d.ts",
|
|
48
48
|
"!dist/__tests__/**",
|
|
49
|
+
"migrations/**/*.sql",
|
|
49
50
|
"npm-shrinkwrap.json",
|
|
50
51
|
"setup/status.schema.json",
|
|
51
52
|
"README.md",
|
package/setup/status.schema.json
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"gateway",
|
|
12
12
|
"transport",
|
|
13
13
|
"auth",
|
|
14
|
+
"workspaces",
|
|
14
15
|
"providers",
|
|
15
16
|
"endpoint_exposure",
|
|
16
17
|
"client_config",
|
|
@@ -70,7 +71,47 @@
|
|
|
70
71
|
"properties": {
|
|
71
72
|
"required": { "type": "boolean" },
|
|
72
73
|
"token_configured": { "type": "boolean" },
|
|
73
|
-
"source": { "enum": ["env", "disabled"] }
|
|
74
|
+
"source": { "enum": ["env", "disabled", "installer-auth-token-file"] },
|
|
75
|
+
"oauth": {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"required": [
|
|
78
|
+
"enabled",
|
|
79
|
+
"registration_policy",
|
|
80
|
+
"clients_configured",
|
|
81
|
+
"shared_secret_enabled",
|
|
82
|
+
"pkce_required",
|
|
83
|
+
"issuer"
|
|
84
|
+
],
|
|
85
|
+
"properties": {
|
|
86
|
+
"enabled": { "type": "boolean" },
|
|
87
|
+
"registration_policy": {
|
|
88
|
+
"enum": ["static_clients", "shared_secret", "open_dev"]
|
|
89
|
+
},
|
|
90
|
+
"clients_configured": { "type": "integer", "minimum": 0 },
|
|
91
|
+
"shared_secret_enabled": { "type": "boolean" },
|
|
92
|
+
"pkce_required": { "type": "boolean" },
|
|
93
|
+
"issuer": { "type": ["string", "null"] }
|
|
94
|
+
},
|
|
95
|
+
"additionalProperties": false
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"additionalProperties": false
|
|
99
|
+
},
|
|
100
|
+
"workspaces": {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"required": [
|
|
103
|
+
"enabled",
|
|
104
|
+
"default",
|
|
105
|
+
"repo_count",
|
|
106
|
+
"allowed_root_count",
|
|
107
|
+
"gateway_app_dir_is_workspace"
|
|
108
|
+
],
|
|
109
|
+
"properties": {
|
|
110
|
+
"enabled": { "type": "boolean" },
|
|
111
|
+
"default": { "type": ["string", "null"] },
|
|
112
|
+
"repo_count": { "type": "integer", "minimum": 0 },
|
|
113
|
+
"allowed_root_count": { "type": "integer", "minimum": 0 },
|
|
114
|
+
"gateway_app_dir_is_workspace": { "type": "boolean" }
|
|
74
115
|
},
|
|
75
116
|
"additionalProperties": false
|
|
76
117
|
},
|