@genesislcap/blank-app-seed 5.27.2-prerelease.3 → 5.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.genx/ai-consumer.json +18 -0
- package/.genx/configure.js +122 -0
- package/.genx/package.json +1 -1
- package/.genx/scripts/check-ai-declaration.mjs +116 -0
- package/.genx/scripts/check-ai-emission.sh +974 -0
- package/.genx/scripts/generate-test-apps.sh +38 -4
- package/.genx/templates/react/ai/assistant-host.ts.hbs +30 -0
- package/.genx/templates/react/ai/assistant.ts.hbs +52 -0
- package/.genx/templates/react/ai/extensions.ts.hbs +17 -0
- package/.genx/templates/react/ai/pbc-elements.ts.hbs +17 -0
- package/.genx/templates/server/ai-service-web-handler.kts.hbs +394 -0
- package/.genx/tests/contracts/ai/ai-resolver-cases.json +6807 -0
- package/.genx/tests/contracts/ai/ui-config-ai.schema.json +203 -0
- package/.genx/tests/fixtures/ai-config-parse-breakers.json +16 -0
- package/.genx/tests/fixtures/ai-config.json +41 -0
- package/.genx/versions.json +3 -3
- package/.github/workflows/build.yml +20 -4
- package/CHANGELOG.md +63 -43
- package/README.md +139 -0
- package/bdd-tests/build.gradle.kts +1 -1
- package/bdd-tests/gradle/wrapper/gradle-wrapper.properties +1 -1
- package/bdd-tests/gradle.properties +0 -1
- package/bdd-tests/settings.gradle.kts +2 -2
- package/client-tmp/angular/package.json +3 -0
- package/client-tmp/react/.oxfmtrc.json +3 -0
- package/client-tmp/react/package.json +6 -2
- package/client-tmp/web-components/package.json +3 -0
- package/client-tmp/web-components/settings.gradle.kts +0 -22
- package/gradle/wrapper/gradle-wrapper.properties +1 -1
- package/package.json +1 -1
- package/server/build.gradle.kts +8 -1
- package/server/gradle/wrapper/gradle-wrapper.properties +1 -1
- package/server/gradle.properties +3 -3
- package/server/settings.gradle.kts +2 -2
- package/server/{{appName}}-app/src/main/genesis/scripts/genesis-router.kts +10 -0
- package/server/{{appName}}-app/src/test/kotlin/global/genesis/EventHandlerTest.kt +1 -1
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
# routes), a "full" app driven by .genx/tests/fixtures/routes-full.json
|
|
7
7
|
# (every tile type: entity-manager with permissions/custom events/eventing/FDC3,
|
|
8
8
|
# grid-pro with listener/reqrep, chart, smart-form), and an "fdc3" app with
|
|
9
|
-
# FDC3 channels enabled
|
|
9
|
+
# FDC3 channels enabled, plus for React an "ai" app driven by .genx/tests/fixtures/ai-config.json
|
|
10
|
+
# (the AI chat panel, whose configuration carries a prompt full of Handlebars) — then runs the ox lint
|
|
11
|
+
# pipeline with zero tolerance:
|
|
10
12
|
#
|
|
11
13
|
# oxlint . --deny-warnings # zero errors, zero warnings
|
|
12
14
|
# oxfmt --check . # formatting is already canonical
|
|
@@ -24,7 +26,9 @@
|
|
|
24
26
|
# By default an app is deleted as soon as it passes, so a run only
|
|
25
27
|
# ever holds one app's node_modules at a time. Failing apps are
|
|
26
28
|
# always kept.
|
|
27
|
-
# BUILD=1 additionally run `npx tsc --noEmit` and `npm run build` per app
|
|
29
|
+
# BUILD=1 additionally run `npx tsc --noEmit` and `npm run build` per app, and for the React
|
|
30
|
+
# ai app check that the assistant is built into a chunk of its own, and that the seed's
|
|
31
|
+
# .genx/ai-consumer.json is within what the installed assistant reads (C-15A.6 S-6)
|
|
28
32
|
#
|
|
29
33
|
# Installs use the app's own bootstrap semantics (plain `npm install`) — NOT
|
|
30
34
|
# --legacy-peer-deps, which would skip the ag-grid peer deps and break builds.
|
|
@@ -33,6 +37,7 @@ set -uo pipefail
|
|
|
33
37
|
|
|
34
38
|
SEED_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
35
39
|
FIXTURE="$SEED_DIR/.genx/tests/fixtures/routes-full.json"
|
|
40
|
+
AI_FIXTURE="$SEED_DIR/.genx/tests/fixtures/ai-config.json"
|
|
36
41
|
|
|
37
42
|
if [ $# -gt 0 ]; then
|
|
38
43
|
FRAMEWORKS=("$@")
|
|
@@ -55,6 +60,23 @@ fi
|
|
|
55
60
|
|
|
56
61
|
FAILURES=()
|
|
57
62
|
|
|
63
|
+
# The assistant loads in a chunk of its own once the layout mounts. The code behind the assistant's
|
|
64
|
+
# registration and the bubble ('pulse-ring' is the bubble's own markup) must be in that chunk and in no
|
|
65
|
+
# other: the entry chunk and the PBC chunk both load at startup. Both names survive minification.
|
|
66
|
+
check_assistant_chunk() {
|
|
67
|
+
local entry chunk marker other
|
|
68
|
+
entry="dist/$(grep -oE 'assets/index-[^"]+\.js' dist/index.html | head -1)"
|
|
69
|
+
chunk="$(ls dist/assets/assistant-*.js 2>/dev/null | head -1)"
|
|
70
|
+
[ -n "$chunk" ] || { echo "no assistant-*.js chunk in dist/assets"; return 1; }
|
|
71
|
+
[ -f "$entry" ] || { echo "no entry chunk named in dist/index.html"; return 1; }
|
|
72
|
+
for marker in registerGenesisAssistant pulse-ring; do
|
|
73
|
+
grep -q "$marker" "$chunk" || { echo "$marker is not in $chunk"; return 1; }
|
|
74
|
+
other="$(grep -l "$marker" dist/assets/*.js | grep -vxF "$chunk")"
|
|
75
|
+
[ -z "$other" ] || { echo "$marker is also in $other"; return 1; }
|
|
76
|
+
done
|
|
77
|
+
! grep -q foundation-ai-chat-bubble "$entry" || { echo "the entry chunk $entry names the chat bubble"; return 1; }
|
|
78
|
+
}
|
|
79
|
+
|
|
58
80
|
run_lint_checks() {
|
|
59
81
|
local app_dir="$1" label="$2"
|
|
60
82
|
(
|
|
@@ -72,12 +94,21 @@ run_lint_checks() {
|
|
|
72
94
|
npx tsc --noEmit || exit 1
|
|
73
95
|
echo "--- [$label] npm run build"
|
|
74
96
|
npm run build || exit 1
|
|
97
|
+
if [ "$label" = "react-ai" ]; then
|
|
98
|
+
echo "--- [$label] the assistant is built into a chunk of its own"
|
|
99
|
+
check_assistant_chunk || exit 1
|
|
100
|
+
echo "--- [$label] the seed's AI declaration is within what the installed assistant reads"
|
|
101
|
+
node "$SEED_DIR/.genx/scripts/check-ai-declaration.mjs" "$SEED_DIR/.genx/ai-consumer.json" . || exit 1
|
|
102
|
+
fi
|
|
75
103
|
fi
|
|
76
104
|
)
|
|
77
105
|
}
|
|
78
106
|
|
|
79
107
|
for fw in "${FRAMEWORKS[@]}"; do
|
|
80
|
-
|
|
108
|
+
variants=(default full fdc3)
|
|
109
|
+
# The chat panel is React only, like the AI gate in configure.js.
|
|
110
|
+
[ "$fw" = "react" ] && variants+=(ai)
|
|
111
|
+
for variant in "${variants[@]}"; do
|
|
81
112
|
label="$fw-$variant"
|
|
82
113
|
app_dir="$WORK_DIR/$label"
|
|
83
114
|
rm -rf "$app_dir"
|
|
@@ -86,6 +117,9 @@ for fw in "${FRAMEWORKS[@]}"; do
|
|
|
86
117
|
if [ "$variant" = "full" ]; then
|
|
87
118
|
extra_args=(--routes "$(cat "$FIXTURE")")
|
|
88
119
|
fi
|
|
120
|
+
if [ "$variant" = "ai" ]; then
|
|
121
|
+
extra_args=(--ui "$(cat "$AI_FIXTURE")")
|
|
122
|
+
fi
|
|
89
123
|
if [ "$variant" = "fdc3" ]; then
|
|
90
124
|
extra_args=(--ui '{"fdc3":{"channels":[{"name":"positions","type":"position"},{"name":"instrumentChannel","type":"fdc3.instrument"}]}}')
|
|
91
125
|
fi
|
|
@@ -115,7 +149,7 @@ if [ ${#FAILURES[@]} -gt 0 ]; then
|
|
|
115
149
|
exit 1
|
|
116
150
|
fi
|
|
117
151
|
|
|
118
|
-
echo "All generated apps are lint-clean: ${FRAMEWORKS[*]} (default + full + fdc3)"
|
|
152
|
+
echo "All generated apps are lint-clean: ${FRAMEWORKS[*]} (default + full + fdc3, and ai for React)"
|
|
119
153
|
if [ "${KEEP:-0}" = "1" ]; then
|
|
120
154
|
echo "Generated apps kept in $WORK_DIR"
|
|
121
155
|
elif [ "$OWNS_WORK_DIR" = "1" ]; then
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { css, customElement, GenesisElement } from '@genesislcap/web-core';
|
|
2
|
+
|
|
3
|
+
const styles = css`
|
|
4
|
+
:host,
|
|
5
|
+
foundation-ai-assistant {
|
|
6
|
+
display: flex;
|
|
7
|
+
width: 100%;
|
|
8
|
+
height: 100%;
|
|
9
|
+
}
|
|
10
|
+
`;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Holds the assistant in the chat bubble's dialog. The bubble gives the assistant its close button by
|
|
14
|
+
* looking for it in this element's shadow root when the dialog opens, so it has to be there by then.
|
|
15
|
+
*
|
|
16
|
+
* The assistant's code loads when this element reaches the page rather than with the app. The bubble
|
|
17
|
+
* and the popout manager around it come from the same package, and upgrade when it arrives.
|
|
18
|
+
*/
|
|
19
|
+
@customElement({ name: 'genesis-app-assistant', styles })
|
|
20
|
+
export class AiAssistantHost extends GenesisElement {
|
|
21
|
+
connectedCallback(): void {
|
|
22
|
+
super.connectedCallback();
|
|
23
|
+
void import('./assistant').then(({ mountAssistant }) => {
|
|
24
|
+
// The layout may have rebuilt its targets while the code loaded.
|
|
25
|
+
if (this.isConnected && this.shadowRoot) {
|
|
26
|
+
mountAssistant(this.shadowRoot);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import '@genesislcap/ai-assistant';
|
|
2
|
+
import type { FoundationAiAssistant } from '@genesislcap/ai-assistant';
|
|
3
|
+
import { type GenesisAiConfig, registerGenesisAssistant } from '@genesislcap/ai-assistant/genesis';
|
|
4
|
+
import { getUser } from '@genesislcap/foundation-user';
|
|
5
|
+
import { extensions } from '../extensions';
|
|
6
|
+
import aiConfig from './ai-config.json';
|
|
7
|
+
|
|
8
|
+
// The right the app's chat proxy demands. Genesis Create grants it through the AI_CHAT_USER profile.
|
|
9
|
+
const AI_CHAT_RIGHT = 'AI_CHAT';
|
|
10
|
+
const NO_RIGHT = `You need the ${AI_CHAT_RIGHT} right to use the assistant. An administrator can add you to the AI_CHAT_USER profile.`;
|
|
11
|
+
|
|
12
|
+
// Registered once, when this module first loads and before any assistant element exists: an element that
|
|
13
|
+
// connects first keeps a provider that does nothing for the rest of the page's life.
|
|
14
|
+
const registration = registerGenesisAssistant({
|
|
15
|
+
config: aiConfig as GenesisAiConfig,
|
|
16
|
+
extensions,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// One assistant for the page, kept across the layout rebuilding its targets (it does on every
|
|
20
|
+
// reconnect). Its conversation is kept in memory under `session-key` for the page's life, one per
|
|
21
|
+
// signed-in user; a reload starts a new one.
|
|
22
|
+
let assistant: FoundationAiAssistant | undefined;
|
|
23
|
+
|
|
24
|
+
// Checked on every mount: the user can change while the layout is off the page (the login page has
|
|
25
|
+
// none), and a block written then never reaches the assistant's store.
|
|
26
|
+
function applyBlocks(target: FoundationAiAssistant): void {
|
|
27
|
+
if (registration.blockedReason) {
|
|
28
|
+
target.setBlocked(true, registration.blockedReason);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
// Without the right the router refuses every call, and the chat would only show a generic error. So
|
|
32
|
+
// the assistant stays blocked, saying why and sending nothing. Only a block of its own is lifted.
|
|
33
|
+
const noRight = !getUser().hasPermission(AI_CHAT_RIGHT);
|
|
34
|
+
const blockedForRight = target.blocked && target.blockedReason === NO_RIGHT;
|
|
35
|
+
if (noRight !== blockedForRight) {
|
|
36
|
+
target.setBlocked(noRight, noRight ? NO_RIGHT : null);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function mountAssistant(host: ParentNode): void {
|
|
41
|
+
const sessionKey = `genesis-app-assistant:${getUser().userName}`;
|
|
42
|
+
if (!assistant) {
|
|
43
|
+
assistant = document.createElement('foundation-ai-assistant') as FoundationAiAssistant;
|
|
44
|
+
assistant.setAttribute('session-key', sessionKey);
|
|
45
|
+
assistant.agents = registration.agents;
|
|
46
|
+
}
|
|
47
|
+
host.append(assistant);
|
|
48
|
+
// Set while connected, so a different user switches the assistant to a session of their own and the
|
|
49
|
+
// previous one, with its history, is torn down.
|
|
50
|
+
assistant.setAttribute('session-key', sessionKey);
|
|
51
|
+
applyBlocks(assistant);
|
|
52
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Your own additions to the AI assistant. This file is yours; Genesis Create's UI Builder may edit it
|
|
2
|
+
// too. It imports nothing, so it still compiles if the assistant is ever removed: the assistant checks
|
|
3
|
+
// it against GenesisAssistantExtensions (from @genesislcap/ai-assistant/genesis) where it reads it.
|
|
4
|
+
//
|
|
5
|
+
// A tool is a definition the model sees and a handler that runs it, under one name, for example:
|
|
6
|
+
//
|
|
7
|
+
// toolDefinitions: [
|
|
8
|
+
// {
|
|
9
|
+
// name: 'current_time',
|
|
10
|
+
// description: 'The current date and time.',
|
|
11
|
+
// parameters: { type: 'object', properties: {} },
|
|
12
|
+
// },
|
|
13
|
+
// ],
|
|
14
|
+
// toolHandlers: { current_time: async () => new Date().toISOString() },
|
|
15
|
+
//
|
|
16
|
+
// A tool may not reuse the name of one the app generates from its data (req_* and event_*).
|
|
17
|
+
export const extensions = {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AppElement } from '@genesislcap/foundation-shell/app';
|
|
2
|
+
import { html } from '@genesislcap/web-core';
|
|
3
|
+
import '../../ai/generated/assistant-host';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The AI assistant, in a chat bubble added after every page's content (the layout's 'layout-end' target).
|
|
7
|
+
*/
|
|
8
|
+
export const aiAssistant: AppElement = {
|
|
9
|
+
targetId: 'layout-end',
|
|
10
|
+
elements: html`
|
|
11
|
+
<foundation-ai-popout-manager>
|
|
12
|
+
<foundation-ai-chat-bubble title="AI Assistant">
|
|
13
|
+
<genesis-app-assistant slot="dialog-content"></genesis-app-assistant>
|
|
14
|
+
</foundation-ai-chat-bubble>
|
|
15
|
+
</foundation-ai-popout-manager>
|
|
16
|
+
`,
|
|
17
|
+
};
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
// Exempts only the build-time auth-map scan: this proxy returns no entity rows. The AI_CHAT right
|
|
2
|
+
// below is still enforced by the router on every call.
|
|
3
|
+
@file:global.genesis.commons.annotation.AuthDisabled
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* AI chat proxy — generated with this application, safe to edit.
|
|
7
|
+
*
|
|
8
|
+
* Your app's own endpoint for the chat panel: the browser talks to this, and this talks to the
|
|
9
|
+
* vendor with YOUR key. The key is read from the environment that starts the server, never from a
|
|
10
|
+
* file in this project: set AI_ANTHROPIC_API_KEY (or AI_GEMINI_API_KEY) there and restart it. A
|
|
11
|
+
* user needs the AI_CHAT right to call these.
|
|
12
|
+
*/
|
|
13
|
+
import com.fasterxml.jackson.core.JsonFactory
|
|
14
|
+
import com.fasterxml.jackson.core.JsonProcessingException
|
|
15
|
+
import com.fasterxml.jackson.core.StreamReadConstraints
|
|
16
|
+
import com.fasterxml.jackson.core.exc.StreamConstraintsException
|
|
17
|
+
import com.fasterxml.jackson.databind.JsonNode
|
|
18
|
+
import com.fasterxml.jackson.databind.ObjectMapper
|
|
19
|
+
import com.fasterxml.jackson.databind.node.ObjectNode
|
|
20
|
+
import global.genesis.config.system.SystemDefinitionService
|
|
21
|
+
import global.genesis.message.core.HttpStatusCode
|
|
22
|
+
import java.net.URI
|
|
23
|
+
import java.net.http.HttpClient
|
|
24
|
+
import java.net.http.HttpRequest as JdkHttpRequest
|
|
25
|
+
import java.net.http.HttpResponse as JdkHttpResponse
|
|
26
|
+
import java.net.http.HttpTimeoutException
|
|
27
|
+
import java.time.Duration
|
|
28
|
+
import kotlinx.coroutines.CancellationException
|
|
29
|
+
import kotlinx.coroutines.future.await
|
|
30
|
+
|
|
31
|
+
webHandlers("ai-service") {
|
|
32
|
+
|
|
33
|
+
val systemDefinition = inject<SystemDefinitionService>()
|
|
34
|
+
// Bounded parsing: at this 5 MiB document limit, a body of tiny objects would build a tree many
|
|
35
|
+
// times its own size. A real chat turn is far inside these limits. The router lets a little more
|
|
36
|
+
// through (6 MiB), so a body just over this limit is answered here, with REQUEST_TOO_LARGE.
|
|
37
|
+
val mapper = ObjectMapper(
|
|
38
|
+
JsonFactory.builder()
|
|
39
|
+
.streamReadConstraints(
|
|
40
|
+
StreamReadConstraints.builder()
|
|
41
|
+
.maxDocumentLength(5_242_880L)
|
|
42
|
+
.maxTokenCount(250_000L)
|
|
43
|
+
.maxNestingDepth(64)
|
|
44
|
+
.build()
|
|
45
|
+
)
|
|
46
|
+
.build()
|
|
47
|
+
)
|
|
48
|
+
val http = HttpClient.newBuilder()
|
|
49
|
+
.connectTimeout(Duration.ofSeconds(20))
|
|
50
|
+
.build()
|
|
51
|
+
|
|
52
|
+
// An upstream status the platform enum does not know would throw on the way out, so anything
|
|
53
|
+
// unrecognised becomes a plain bad gateway. 529 (the vendors' "overloaded") is not in the enum
|
|
54
|
+
// either, and it maps to 503 so a client's own retry ladder treats it as retryable.
|
|
55
|
+
fun upstreamStatus(code: Int): HttpStatusCode = when (code) {
|
|
56
|
+
529 -> HttpStatusCode.ServiceUnavailable
|
|
57
|
+
else -> try {
|
|
58
|
+
HttpStatusCode.valueOf(code)
|
|
59
|
+
} catch (e: IllegalArgumentException) {
|
|
60
|
+
HttpStatusCode.BadGateway
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A system-definition read can throw: an encrypted item with a missing or wrong GenesisKey. So every
|
|
65
|
+
// read goes through here, and only the exception's type is logged. One that escaped would be logged
|
|
66
|
+
// by the platform with the caller's session, and its message (the key's length, even) could reach
|
|
67
|
+
// the browser.
|
|
68
|
+
fun readItem(name: String): Result<String?> = try {
|
|
69
|
+
Result.success(systemDefinition.get(name).orElse(null))
|
|
70
|
+
} catch (e: Exception) {
|
|
71
|
+
LOG.warn("ai-service: could not read " + name + ": " + e.javaClass.simpleName)
|
|
72
|
+
Result.failure(e)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The keys come straight from the process environment, never from the system definition: a build
|
|
76
|
+
// writes every system-definition item, a GENESIS_SYSDEF_ one included, in plain text into
|
|
77
|
+
// build/genesis-home/generated/cfg/generated-system-definition.json, so a key read that way ends
|
|
78
|
+
// up in every copy of build/.
|
|
79
|
+
fun keyFor(vendor: String): String = java.lang.System.getenv("AI_" + vendor + "_API_KEY")?.trim() ?: ""
|
|
80
|
+
|
|
81
|
+
// A key set the old way is not used: the proxy answers as if none were set. A plain one is already
|
|
82
|
+
// in the build files. Said once, without the value.
|
|
83
|
+
for (vendor in listOf("ANTHROPIC", "GEMINI")) {
|
|
84
|
+
if (java.lang.System.getenv("GENESIS_SYSDEF_AI_" + vendor + "_API_KEY") != null) {
|
|
85
|
+
LOG.warn(
|
|
86
|
+
"ai-service: GENESIS_SYSDEF_AI_" + vendor + "_API_KEY is written into the app's build files, " +
|
|
87
|
+
"so it is not used; set AI_" + vendor + "_API_KEY instead, delete the app's build " +
|
|
88
|
+
"directory and rotate the key"
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
if (java.lang.System.getenv("GENESIS_ENCRYPTED_SYSDEF_AI_" + vendor + "_API_KEY") != null) {
|
|
92
|
+
LOG.warn(
|
|
93
|
+
"ai-service: GENESIS_ENCRYPTED_SYSDEF_AI_" + vendor + "_API_KEY is not read; set AI_" + vendor +
|
|
94
|
+
"_API_KEY instead"
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The limits this app was generated with. They live here, not in a system-definition file,
|
|
100
|
+
// because a generator may rewrite that file; a system-definition item of the same name overrides
|
|
101
|
+
// them (GENESIS_SYSDEF_AI_ALLOWED_MODELS, GENESIS_SYSDEF_AI_MAX_OUTPUT_TOKENS).
|
|
102
|
+
val defaultAllowedModels = "{{AI.allowedModels}}"
|
|
103
|
+
val defaultMaxOutputTokens = "{{AI.maxOutputTokens}}"
|
|
104
|
+
|
|
105
|
+
// The model a request asks for has to be one this app allows, whatever the browser sent: the
|
|
106
|
+
// panel is not the only thing that can post here. AI_ALLOWED_MODELS set but EMPTY refuses every
|
|
107
|
+
// model, so a setting that failed to load is never an open door to the most expensive one.
|
|
108
|
+
val allowedModels = (readItem("AI_ALLOWED_MODELS").getOrElse { "" } ?: defaultAllowedModels)
|
|
109
|
+
.split(",")
|
|
110
|
+
.map { it.trim() }
|
|
111
|
+
.filter { it.isNotEmpty() }
|
|
112
|
+
if (allowedModels.isEmpty()) {
|
|
113
|
+
LOG.warn("ai-service: AI_ALLOWED_MODELS is empty or unreadable, so every chat request will be refused")
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// A cap is a whole number from 1 to 1000000. An override that is not one is ignored, with a
|
|
117
|
+
// warning that names the item, rather than turning into a cap nobody chose.
|
|
118
|
+
fun tokenCap(raw: String?): Int? = raw?.trim()?.toIntOrNull()?.takeIf { it in 1..1_000_000 }
|
|
119
|
+
val generatedMaxOutputTokens = tokenCap(defaultMaxOutputTokens) ?: 16000
|
|
120
|
+
val maxOutputTokens = readItem("AI_MAX_OUTPUT_TOKENS").getOrNull()?.let { raw ->
|
|
121
|
+
tokenCap(raw) ?: generatedMaxOutputTokens.also {
|
|
122
|
+
LOG.warn("ai-service: AI_MAX_OUTPUT_TOKENS is not a whole number from 1 to 1000000, so the generated default applies")
|
|
123
|
+
}
|
|
124
|
+
} ?: generatedMaxOutputTokens
|
|
125
|
+
|
|
126
|
+
// Clamp DOWN to the cap, and never below 1: a request asking for less keeps what it asked for.
|
|
127
|
+
fun clamp(requested: Int): Int = requested.coerceIn(1, maxOutputTokens)
|
|
128
|
+
|
|
129
|
+
// A model id is also a path segment in the Gemini URL, so even an allowed one must be a bare id.
|
|
130
|
+
val modelId = Regex("[A-Za-z0-9][A-Za-z0-9._-]*")
|
|
131
|
+
|
|
132
|
+
fun modelAllowed(model: String): Boolean = modelId.matches(model) && allowedModels.contains(model)
|
|
133
|
+
|
|
134
|
+
// A key with a character a request header cannot carry (a pasted zero-width space, a smart quote)
|
|
135
|
+
// makes the request builder throw, and the platform echoes that exception, key and all.
|
|
136
|
+
val keyChars = Regex("[\\x21-\\x7E]+")
|
|
137
|
+
|
|
138
|
+
// Whatever a vendor sends back, the key does not go to the browser in it.
|
|
139
|
+
fun withoutKey(text: String, apiKey: String): String = text.replace(apiKey, "[redacted]")
|
|
140
|
+
|
|
141
|
+
grouping("anthropic") {
|
|
142
|
+
// No transaction: the proxy touches no rows, and one would hold a pooled database connection
|
|
143
|
+
// for the whole vendor call, starving every other request that needs one.
|
|
144
|
+
endpoint<String, Any>(POST, "chat", transactional = false) {
|
|
145
|
+
// No produces() and no accepts(): each adds a required header, matched by its exact name,
|
|
146
|
+
// so the panel's Accept: application/x-ndjson, or a proxy sending a lowercase content-type,
|
|
147
|
+
// would get a 406. The body is parsed as JSON anyway, and replies go out as JSON.
|
|
148
|
+
|
|
149
|
+
// Never set requiresAuth = false: it makes this endpoint anonymous and drops the AI_CHAT
|
|
150
|
+
// check, and the build's security scan still reports the endpoint as secure.
|
|
151
|
+
permissioning {
|
|
152
|
+
permissionCodes("AI_CHAT")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
handleRequest {
|
|
156
|
+
val apiKey = keyFor("ANTHROPIC")
|
|
157
|
+
if (apiKey.isEmpty()) {
|
|
158
|
+
// 424, never 503: a client retries a 503 and the user waits instead of being
|
|
159
|
+
// told the one thing that fixes this.
|
|
160
|
+
error(
|
|
161
|
+
HttpStatusCode.FailedDependency,
|
|
162
|
+
mapOf(
|
|
163
|
+
"code" to "NO_API_KEY",
|
|
164
|
+
"error" to "No anthropic API key is configured for this application. " +
|
|
165
|
+
"Set AI_ANTHROPIC_API_KEY in the server's environment and restart."
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
if (!keyChars.matches(apiKey)) {
|
|
170
|
+
error(
|
|
171
|
+
HttpStatusCode.FailedDependency,
|
|
172
|
+
mapOf(
|
|
173
|
+
"code" to "BAD_API_KEY",
|
|
174
|
+
"error" to "The anthropic API key configured for this application contains characters " +
|
|
175
|
+
"an API key cannot have. Set AI_ANTHROPIC_API_KEY again and restart."
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
val payload = try {
|
|
181
|
+
mapper.readTree(body) as? ObjectNode
|
|
182
|
+
} catch (e: StreamConstraintsException) {
|
|
183
|
+
error(
|
|
184
|
+
HttpStatusCode.RequestTooLong,
|
|
185
|
+
mapOf("code" to "REQUEST_TOO_LARGE", "error" to "The request is larger or more deeply nested than this endpoint accepts.")
|
|
186
|
+
)
|
|
187
|
+
} catch (e: JsonProcessingException) {
|
|
188
|
+
null
|
|
189
|
+
} ?: error(
|
|
190
|
+
HttpStatusCode.BadRequest,
|
|
191
|
+
mapOf("code" to "BAD_REQUEST", "error" to "The request body must be a JSON object.")
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
// The chat panel never streams through this proxy, and a streamed reply would hold the
|
|
195
|
+
// call open with no timeout once its headers arrived.
|
|
196
|
+
if (payload.path("stream").asBoolean(false)) {
|
|
197
|
+
error(
|
|
198
|
+
HttpStatusCode.BadRequest,
|
|
199
|
+
mapOf("code" to "BAD_REQUEST", "error" to "Streaming is not supported by this endpoint.")
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
val model = payload.path("model").asText("")
|
|
204
|
+
if (!modelAllowed(model)) {
|
|
205
|
+
error(
|
|
206
|
+
HttpStatusCode.BadRequest,
|
|
207
|
+
mapOf(
|
|
208
|
+
"code" to "MODEL_NOT_ALLOWED",
|
|
209
|
+
"error" to "Model '" + model + "' is not in AI_ALLOWED_MODELS for this application.",
|
|
210
|
+
"allowedModels" to allowedModels
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
val requestedMaxTokens = payload.path("max_tokens").asInt(maxOutputTokens)
|
|
216
|
+
payload.put("max_tokens", clamp(requestedMaxTokens))
|
|
217
|
+
|
|
218
|
+
// No fallback models: each would be another model and token size to hold to the limits
|
|
219
|
+
// above, and Anthropic needs a beta header to accept them. Refused, not passed through.
|
|
220
|
+
val fallbacks = payload.get("fallbacks")
|
|
221
|
+
if (fallbacks != null && !fallbacks.isNull) {
|
|
222
|
+
error(
|
|
223
|
+
HttpStatusCode.BadRequest,
|
|
224
|
+
mapOf("code" to "FALLBACKS_NOT_SUPPORTED", "error" to "Fallback models are not supported by this endpoint.")
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
val response = try {
|
|
229
|
+
val upstream = JdkHttpRequest.newBuilder()
|
|
230
|
+
.uri(URI.create("https://api.anthropic.com/v1/messages"))
|
|
231
|
+
.timeout(Duration.ofSeconds(280))
|
|
232
|
+
.header("content-type", "application/json")
|
|
233
|
+
.header("x-api-key", apiKey)
|
|
234
|
+
.header("anthropic-version", "2023-06-01")
|
|
235
|
+
.POST(JdkHttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload)))
|
|
236
|
+
.build()
|
|
237
|
+
// Async, so no thread is held while the vendor works. Nothing cancels the call once it is
|
|
238
|
+
// sent: a caller who goes away does not stop it, and it is billed as usual.
|
|
239
|
+
http.sendAsync(upstream, JdkHttpResponse.BodyHandlers.ofString()).await()
|
|
240
|
+
} catch (e: CancellationException) {
|
|
241
|
+
throw e
|
|
242
|
+
} catch (e: Exception) {
|
|
243
|
+
// Only the type is logged: an exception's message can carry what the request was
|
|
244
|
+
// built from, and one that escapes is logged by the platform with the caller's cookies.
|
|
245
|
+
LOG.warn("ai-service: anthropic call failed: " + e.javaClass.simpleName)
|
|
246
|
+
val timedOut = e is HttpTimeoutException || e.cause is HttpTimeoutException
|
|
247
|
+
error(
|
|
248
|
+
if (timedOut) HttpStatusCode.GatewayTimeout else HttpStatusCode.BadGateway,
|
|
249
|
+
mapOf("code" to "VENDOR_UNREACHABLE", "error" to "The AI vendor could not be reached. Try again.")
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (response.statusCode() !in 200..299) {
|
|
254
|
+
// The vendor's own body travels back untouched — a billing refusal or a rate
|
|
255
|
+
// limit says more than anything this proxy could put in its place.
|
|
256
|
+
LOG.warn("ai-service: anthropic returned " + response.statusCode())
|
|
257
|
+
error(upstreamStatus(response.statusCode()), withoutKey(response.body(), apiKey))
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Declared Any, so the router writes this String as its own bytes labelled application/json,
|
|
261
|
+
// whatever the browser's Accept says. (A String type would label it with that Accept, and
|
|
262
|
+
// buildResponse/jsonBody grows the router's composer cache by one entry per reply.)
|
|
263
|
+
withoutKey(response.body(), apiKey)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
grouping("gemini") {
|
|
269
|
+
// No transaction: the proxy touches no rows, and one would hold a pooled database connection
|
|
270
|
+
// for the whole vendor call, starving every other request that needs one.
|
|
271
|
+
endpoint<String, Any>(POST, "chat", transactional = false) {
|
|
272
|
+
// No produces() and no accepts(): each adds a required header, matched by its exact name,
|
|
273
|
+
// so the panel's Accept: application/x-ndjson, or a proxy sending a lowercase content-type,
|
|
274
|
+
// would get a 406. The body is parsed as JSON anyway, and replies go out as JSON.
|
|
275
|
+
|
|
276
|
+
// Never set requiresAuth = false: it makes this endpoint anonymous and drops the AI_CHAT
|
|
277
|
+
// check, and the build's security scan still reports the endpoint as secure.
|
|
278
|
+
permissioning {
|
|
279
|
+
permissionCodes("AI_CHAT")
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
handleRequest {
|
|
283
|
+
val apiKey = keyFor("GEMINI")
|
|
284
|
+
if (apiKey.isEmpty()) {
|
|
285
|
+
// 424, never 503: a client retries a 503 and the user waits instead of being
|
|
286
|
+
// told the one thing that fixes this.
|
|
287
|
+
error(
|
|
288
|
+
HttpStatusCode.FailedDependency,
|
|
289
|
+
mapOf(
|
|
290
|
+
"code" to "NO_API_KEY",
|
|
291
|
+
"error" to "No gemini API key is configured for this application. " +
|
|
292
|
+
"Set AI_GEMINI_API_KEY in the server's environment and restart."
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
}
|
|
296
|
+
if (!keyChars.matches(apiKey)) {
|
|
297
|
+
error(
|
|
298
|
+
HttpStatusCode.FailedDependency,
|
|
299
|
+
mapOf(
|
|
300
|
+
"code" to "BAD_API_KEY",
|
|
301
|
+
"error" to "The gemini API key configured for this application contains characters " +
|
|
302
|
+
"an API key cannot have. Set AI_GEMINI_API_KEY again and restart."
|
|
303
|
+
)
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
val payload = try {
|
|
308
|
+
mapper.readTree(body) as? ObjectNode
|
|
309
|
+
} catch (e: StreamConstraintsException) {
|
|
310
|
+
error(
|
|
311
|
+
HttpStatusCode.RequestTooLong,
|
|
312
|
+
mapOf("code" to "REQUEST_TOO_LARGE", "error" to "The request is larger or more deeply nested than this endpoint accepts.")
|
|
313
|
+
)
|
|
314
|
+
} catch (e: JsonProcessingException) {
|
|
315
|
+
null
|
|
316
|
+
} ?: error(
|
|
317
|
+
HttpStatusCode.BadRequest,
|
|
318
|
+
mapOf("code" to "BAD_REQUEST", "error" to "The request body must be a JSON object.")
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
val model = payload.path("model").asText("")
|
|
322
|
+
if (!modelAllowed(model)) {
|
|
323
|
+
error(
|
|
324
|
+
HttpStatusCode.BadRequest,
|
|
325
|
+
mapOf(
|
|
326
|
+
"code" to "MODEL_NOT_ALLOWED",
|
|
327
|
+
"error" to "Model '" + model + "' is not in AI_ALLOWED_MODELS for this application.",
|
|
328
|
+
"allowedModels" to allowedModels
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// The model addresses the endpoint in the URL, so it must not also ride in the body.
|
|
334
|
+
payload.remove("model")
|
|
335
|
+
|
|
336
|
+
// Gemini also reads the snake_case spellings, which would sit beside the camelCase fields
|
|
337
|
+
// the limits below set, and escape them. The chat panel never sends them.
|
|
338
|
+
val snakeCase = listOf("generation_config").filter { payload.has(it) } +
|
|
339
|
+
listOf("max_output_tokens", "candidate_count").filter { payload.path("generationConfig").has(it) }
|
|
340
|
+
if (snakeCase.isNotEmpty()) {
|
|
341
|
+
error(
|
|
342
|
+
HttpStatusCode.BadRequest,
|
|
343
|
+
mapOf("code" to "BAD_REQUEST", "error" to "Use the camelCase field names; " + snakeCase.joinToString() + " is not accepted.")
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
val generationConfig = payload.path("generationConfig") as? ObjectNode
|
|
348
|
+
?: mapper.createObjectNode().also { payload.set<JsonNode>("generationConfig", it) }
|
|
349
|
+
val requestedMaxTokens = generationConfig.path("maxOutputTokens").asInt(maxOutputTokens)
|
|
350
|
+
generationConfig.put("maxOutputTokens", clamp(requestedMaxTokens))
|
|
351
|
+
// One candidate: each extra one is generated, and billed, up to the same ceiling again.
|
|
352
|
+
if (generationConfig.has("candidateCount")) {
|
|
353
|
+
generationConfig.put("candidateCount", 1)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
val response = try {
|
|
357
|
+
val upstream = JdkHttpRequest.newBuilder()
|
|
358
|
+
.uri(URI.create("https://generativelanguage.googleapis.com/v1beta/models/" + model + ":generateContent"))
|
|
359
|
+
.timeout(Duration.ofSeconds(280))
|
|
360
|
+
.header("content-type", "application/json")
|
|
361
|
+
.header("x-goog-api-key", apiKey)
|
|
362
|
+
.POST(JdkHttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload)))
|
|
363
|
+
.build()
|
|
364
|
+
// Async, so no thread is held while the vendor works. Nothing cancels the call once it is
|
|
365
|
+
// sent: a caller who goes away does not stop it, and it is billed as usual.
|
|
366
|
+
http.sendAsync(upstream, JdkHttpResponse.BodyHandlers.ofString()).await()
|
|
367
|
+
} catch (e: CancellationException) {
|
|
368
|
+
throw e
|
|
369
|
+
} catch (e: Exception) {
|
|
370
|
+
// Only the type is logged: an exception's message can carry what the request was
|
|
371
|
+
// built from, and one that escapes is logged by the platform with the caller's cookies.
|
|
372
|
+
LOG.warn("ai-service: gemini call failed: " + e.javaClass.simpleName)
|
|
373
|
+
val timedOut = e is HttpTimeoutException || e.cause is HttpTimeoutException
|
|
374
|
+
error(
|
|
375
|
+
if (timedOut) HttpStatusCode.GatewayTimeout else HttpStatusCode.BadGateway,
|
|
376
|
+
mapOf("code" to "VENDOR_UNREACHABLE", "error" to "The AI vendor could not be reached. Try again.")
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (response.statusCode() !in 200..299) {
|
|
381
|
+
// The vendor's own body travels back untouched — a billing refusal or a rate
|
|
382
|
+
// limit says more than anything this proxy could put in its place.
|
|
383
|
+
LOG.warn("ai-service: gemini returned " + response.statusCode())
|
|
384
|
+
error(upstreamStatus(response.statusCode()), withoutKey(response.body(), apiKey))
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Declared Any, so the router writes this String as its own bytes labelled application/json,
|
|
388
|
+
// whatever the browser's Accept says. (A String type would label it with that Accept, and
|
|
389
|
+
// buildResponse/jsonBody grows the router's composer cache by one entry per reply.)
|
|
390
|
+
withoutKey(response.body(), apiKey)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|